feat: add markdown rendering and resizable chat panel (#104)

* feat: add markdown rendering for chat messages

- Add react-markdown and @tailwindcss/typography for markdown support
- Use prose styling for assistant message formatting
- Fix Radix ScrollArea viewport horizontal overflow issue
- Add CSS fix for viewport width constraint

* feat: add resizable chat panel

- Replace fixed width layout with react-resizable-panels
- Chat panel can be resized by dragging the handle
- Panel is collapsible with min 15% and max 50% width
- Ctrl+B keyboard shortcut still works for toggle
This commit is contained in:
Dayuan Jiang
2025-12-05 22:42:39 +09:00
committed by GitHub
parent 3fb349fb3e
commit 0af5229477
8 changed files with 714 additions and 145 deletions

View File

@@ -2,6 +2,7 @@
import { useRef, useEffect, useState, useCallback } from "react";
import Image from "next/image";
import ReactMarkdown from "react-markdown";
import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai";
@@ -281,11 +282,11 @@ export function ChatMessageDisplay({
};
return (
<ScrollArea className="h-full px-4 scrollbar-thin">
<ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
<div className="py-4 space-y-4">
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
const isLastAssistantMessage = message.role === "assistant" && (
@@ -300,7 +301,7 @@ export function ChatMessageDisplay({
return (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }}
>
{message.role === "user" && userMessageText && !isEditing && (
@@ -333,7 +334,7 @@ export function ChatMessageDisplay({
</button>
</div>
)}
<div className="max-w-[85%]">
<div className="max-w-[85%] min-w-0">
{/* Edit mode for user messages */}
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
@@ -405,8 +406,8 @@ export function ChatMessageDisplay({
switch (part.type) {
case "text":
return (
<div key={index} className="whitespace-pre-wrap break-words">
{part.text}
<div key={index} className="prose prose-sm dark:prose-invert max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown>{part.text}</ReactMarkdown>
</div>
);
case "file":

View File

@@ -4,7 +4,12 @@ import type React from "react";
import { useRef, useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen, Settings } from "lucide-react";
import {
PanelRightClose,
PanelRightOpen,
Settings,
CheckCircle,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
@@ -16,7 +21,10 @@ import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
import { SettingsDialog, STORAGE_ACCESS_CODE_KEY } from "@/components/settings-dialog";
import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog";
interface ChatPanelProps {
isVisible: boolean;
@@ -75,7 +83,9 @@ export default function ChatPanel({
}, []);
// Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
);
// Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
@@ -86,87 +96,83 @@ export default function ChatPanel({
chartXMLRef.current = chartXML;
}, [chartXML]);
const {
messages,
sendMessage,
addToolResult,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string };
const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string };
const validationError = validateMxCellStructure(xml);
const validationError = validateMxCellStructure(xml);
if (validationError) {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: validationError,
});
} else {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
});
}
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>;
};
let currentXml = "";
try {
console.log("[edit_diagram] Starting...");
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current;
if (cachedXML) {
currentXml = cachedXML;
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length
);
if (validationError) {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: validationError,
});
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO..."
);
currentXml = await onFetchChart(false);
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length
);
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
});
}
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>;
};
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
let currentXml = "";
try {
console.log("[edit_diagram] Starting...");
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current;
if (cachedXML) {
currentXml = cachedXML;
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length
);
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO..."
);
currentXml = await onFetchChart(false);
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length
);
}
onDisplayChart(editedXml);
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
});
console.log("[edit_diagram] Success");
} catch (error) {
console.error("[edit_diagram] Failed:", error);
onDisplayChart(editedXml);
const errorMessage =
error instanceof Error ? error.message : String(error);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
});
console.log("[edit_diagram] Success");
} catch (error) {
console.error("[edit_diagram] Failed:", error);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage}
const errorMessage =
error instanceof Error
? error.message
: String(error);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage}
Current diagram XML:
\`\`\`xml
@@ -174,34 +180,34 @@ ${currentXml || "No XML available"}
\`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
});
});
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error);
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error);
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: 'system' as const,
content: error.message,
parts: [{ type: 'text' as const, text: error.message }]
};
return [...currentMessages, errorMessage];
});
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: error.message,
parts: [{ type: "text" as const, text: error.message }],
};
return [...currentMessages, errorMessage];
});
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true);
setShowSettingsDialog(true);
}
},
});
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true);
setShowSettingsDialog(true);
}
},
});
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -246,7 +252,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const messageIndex = messages.length;
xmlSnapshotsRef.current.set(messageIndex, chartXml);
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
sendMessage(
{ parts },
{
@@ -428,7 +435,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Full view
return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<Toaster position="bottom-center" richColors style={{ position: "absolute" }} />
<Toaster
position="bottom-center"
richColors
style={{ position: "absolute" }}
/>
{/* Header */}
<header className="px-5 py-4 border-b border-border/50">
<div className="flex items-center justify-between">
@@ -486,7 +497,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</header>
{/* Messages */}
<main className="flex-1 overflow-hidden">
<main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay
messages={messages}
setInput={setInput}
@@ -507,7 +518,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClearChat={() => {
setMessages([]);
clearDiagram();
setSessionId(`session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
setSessionId(
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
);
xmlSnapshotsRef.current.clear();
}}
files={files}

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -18,7 +18,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden"
>
{children}
</ScrollAreaPrimitive.Viewport>