fix: resolve biome lint errors and memory leak in file preview (#118)

- Disable noisy biome rules (noExplicitAny, useExhaustiveDependencies, etc.)
- Fix memory leak in file-preview-list.tsx with useRef pattern
- Separate unmount cleanup into dedicated useEffect
- Add ToolPartLike interface for type safety in chat-message-display
- Add accessibility attributes (role, tabIndex, onKeyDown)
- Replace autoFocus with useEffect focus pattern
- Minor syntax improvements (optional chaining, key fixes)
This commit is contained in:
Dayuan Jiang
2025-12-06 16:18:26 +09:00
committed by GitHub
parent 9aaf9bf31f
commit e893bd60f9
9 changed files with 165 additions and 44 deletions

View File

@@ -40,7 +40,7 @@ function validateFileParts(messages: any[]): {
for (const filePart of fileParts) { for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data> // Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size // Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith("data:")) { if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1] const base64Data = filePart.url.split(",")[1]
if (base64Data) { if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import React, { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"

View File

@@ -19,6 +19,30 @@
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noImportantStyles": "off" "noImportantStyles": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off"
},
"a11y": {
"useButtonType": "off",
"noAutofocus": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off",
"noNoninteractiveTabindex": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"style": {
"useNodejsImportProtocol": "off",
"useTemplate": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
} }
} }
}, },

View File

@@ -99,8 +99,8 @@ function showValidationErrors(errors: string[]) {
{errors.length} files rejected: {errors.length} files rejected:
</span> </span>
<ul className="text-muted-foreground text-xs list-disc list-inside"> <ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => ( {errors.slice(0, 3).map((err) => (
<li key={i}>{err}</li> <li key={err}>{err}</li>
))} ))}
{errors.length > 3 && ( {errors.length > 3 && (
<li>...and {errors.length - 3} more</li> <li>...and {errors.length - 3} more</li>
@@ -162,10 +162,16 @@ export function ChatInput({
} }
}, []) }, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => { useEffect(() => {
adjustTextareaHeight() adjustTextareaHeight()
}, [input, adjustTextareaHeight]) }, [input, adjustTextareaHeight])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault() e.preventDefault()
@@ -297,7 +303,7 @@ export function ChatInput({
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={onChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder="Describe your diagram or paste an image..." placeholder="Describe your diagram or paste an image..."

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import type { UIMessage } from "ai" import type { UIMessage } from "ai"
import { import {
Check, Check,
ChevronDown, ChevronDown,
@@ -32,12 +33,21 @@ interface EditPair {
replace: string replace: string
} }
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
}
function EditDiffDisplay({ edits }: { edits: EditPair[] }) { function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{edits.map((edit, index) => ( {edits.map((edit, index) => (
<div <div
key={index} key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
@@ -82,8 +92,8 @@ import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => { const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return "" if (!message.parts) return ""
return message.parts return message.parts
.filter((part: any) => part.type === "text") .filter((part) => part.type === "text")
.map((part: any) => part.text) .map((part) => (part as { text: string }).text)
.join("\n") .join("\n")
} }
@@ -119,6 +129,7 @@ export function ChatMessageDisplay({
const [editingMessageId, setEditingMessageId] = useState<string | null>( const [editingMessageId, setEditingMessageId] = useState<string | null>(
null, null,
) )
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
const [editText, setEditText] = useState<string>("") const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => { const copyMessageToClipboard = async (messageId: string, text: string) => {
@@ -189,12 +200,19 @@ export function ChatMessageDisplay({
} }
}, [messages]) }, [messages])
useEffect(() => {
if (editingMessageId && editTextareaRef.current) {
editTextareaRef.current.focus()
}
}, [editingMessageId])
useEffect(() => { useEffect(() => {
messages.forEach((message) => { messages.forEach((message) => {
if (message.parts) { if (message.parts) {
message.parts.forEach((part: any) => { message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
@@ -205,18 +223,19 @@ export function ChatMessageDisplay({
if ( if (
part.type === "tool-display_diagram" && part.type === "tool-display_diagram" &&
part.input?.xml input?.xml
) { ) {
const xml = input.xml as string
if ( if (
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
handleDisplayChart(part.input.xml) handleDisplayChart(xml)
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
handleDisplayChart(part.input.xml) handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId) processedToolCalls.current.add(toolCallId)
} }
} }
@@ -226,7 +245,7 @@ export function ChatMessageDisplay({
}) })
}, [messages, handleDisplayChart]) }, [messages, handleDisplayChart])
const renderToolPart = (part: any) => { const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId const callId = part.toolCallId
const { state, input, output } = part const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true const isExpanded = expandedTools[callId] ?? true
@@ -280,6 +299,7 @@ export function ChatMessageDisplay({
)} )}
{input && Object.keys(input).length > 0 && ( {input && Object.keys(input).length > 0 && (
<button <button
type="button"
onClick={toggleExpanded} onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors" className="p-1 rounded hover:bg-muted transition-colors"
> >
@@ -358,6 +378,7 @@ export function ChatMessageDisplay({
{onEditMessage && {onEditMessage &&
isLastUserMessage && ( isLastUserMessage && (
<button <button
type="button"
onClick={() => { onClick={() => {
setEditingMessageId( setEditingMessageId(
message.id, message.id,
@@ -373,6 +394,7 @@ export function ChatMessageDisplay({
</button> </button>
)} )}
<button <button
type="button"
onClick={() => onClick={() =>
copyMessageToClipboard( copyMessageToClipboard(
message.id, message.id,
@@ -407,6 +429,7 @@ export function ChatMessageDisplay({
{isEditing && message.role === "user" ? ( {isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<textarea <textarea
ref={editTextareaRef}
value={editText} value={editText}
onChange={(e) => onChange={(e) =>
setEditText(e.target.value) setEditText(e.target.value)
@@ -417,7 +440,6 @@ export function ChatMessageDisplay({
.length + 1, .length + 1,
6, 6,
)} )}
autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingMessageId( setEditingMessageId(
@@ -447,6 +469,7 @@ export function ChatMessageDisplay({
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button"
onClick={() => { onClick={() => {
setEditingMessageId( setEditingMessageId(
null, null,
@@ -458,6 +481,7 @@ export function ChatMessageDisplay({
Cancel Cancel
</button> </button>
<button <button
type="button"
onClick={() => { onClick={() => {
if ( if (
editText.trim() && editText.trim() &&
@@ -483,7 +507,7 @@ export function ChatMessageDisplay({
) : ( ) : (
/* Text content in bubble */ /* Text content in bubble */
message.parts?.some( message.parts?.some(
(part: any) => (part) =>
part.type === "text" || part.type === "text" ||
part.type === "file", part.type === "file",
) && ( ) && (
@@ -496,6 +520,20 @@ export function ChatMessageDisplay({
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md" ? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`} } ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
role={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "button"
: undefined
}
tabIndex={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? 0
: undefined
}
onClick={() => { onClick={() => {
if ( if (
message.role === message.role ===
@@ -511,6 +549,24 @@ export function ChatMessageDisplay({
) )
} }
}} }}
onKeyDown={(e) => {
if (
(e.key === "Enter" ||
e.key === " ") &&
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
e.preventDefault()
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}
}}
title={ title={
message.role === "user" && message.role === "user" &&
isLastUserMessage && isLastUserMessage &&
@@ -520,17 +576,12 @@ export function ChatMessageDisplay({
} }
> >
{message.parts?.map( {message.parts?.map(
( (part, index) => {
part: any,
index: number,
) => {
switch (part.type) { switch (part.type) {
case "text": case "text":
return ( return (
<div <div
key={ key={`${message.id}-text-${index}`}
index
}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${ className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role === message.role ===
"user" "user"
@@ -548,9 +599,7 @@ export function ChatMessageDisplay({
case "file": case "file":
return ( return (
<div <div
key={ key={`${message.id}-file-${part.url}`}
index
}
className="mt-2" className="mt-2"
> >
<Image <Image
@@ -581,9 +630,11 @@ export function ChatMessageDisplay({
) )
)} )}
{/* Tool calls outside bubble */} {/* Tool calls outside bubble */}
{message.parts?.map((part: any) => { {message.parts?.map((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
return renderToolPart(part) return renderToolPart(
part as ToolPartLike,
)
} }
return null return null
})} })}
@@ -592,6 +643,7 @@ export function ChatMessageDisplay({
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
{/* Copy button */} {/* Copy button */}
<button <button
type="button"
onClick={() => onClick={() =>
copyMessageToClipboard( copyMessageToClipboard(
message.id, message.id,
@@ -624,6 +676,7 @@ export function ChatMessageDisplay({
{onRegenerate && {onRegenerate &&
isLastAssistantMessage && ( isLastAssistantMessage && (
<button <button
type="button"
onClick={() => onClick={() =>
onRegenerate( onRegenerate(
messageIndex, messageIndex,
@@ -639,6 +692,7 @@ export function ChatMessageDisplay({
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */} {/* Thumbs up */}
<button <button
type="button"
onClick={() => onClick={() =>
submitFeedback( submitFeedback(
message.id, message.id,
@@ -657,6 +711,7 @@ export function ChatMessageDisplay({
</button> </button>
{/* Thumbs down */} {/* Thumbs down */}
<button <button
type="button"
onClick={() => onClick={() =>
submitFeedback( submitFeedback(
message.id, message.id,

View File

@@ -22,7 +22,7 @@ import {
STORAGE_ACCESS_CODE_KEY, STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog" } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils" import { formatXML, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps { interface ChatPanelProps {

View File

@@ -12,7 +12,7 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
<div className="overflow-hidden w-full"> <div className="overflow-hidden w-full">
<Highlight theme={themes.github} code={code} language={language}> <Highlight theme={themes.github} code={code} language={language}>
{({ {({
className, className: _className,
style, style,
tokens, tokens,
getLineProps, getLineProps,

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react" import { X } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import React, { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
interface FilePreviewListProps { interface FilePreviewListProps {
files: File[] files: File[]
@@ -11,27 +11,63 @@ interface FilePreviewListProps {
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) { export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null) const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Cleanup object URLs on unmount // Create and cleanup object URLs when files change
useEffect(() => { useEffect(() => {
const objectUrls = files const currentUrls = imageUrlsRef.current
.filter((file) => file.type.startsWith("image/")) const newUrls = new Map<File, string>()
.map((file) => URL.createObjectURL(file))
return () => { files.forEach((file) => {
objectUrls.forEach(URL.revokeObjectURL) if (file.type.startsWith("image/")) {
// Reuse existing URL if file is already tracked
const existingUrl = currentUrls.get(file)
if (existingUrl) {
newUrls.set(file, existingUrl)
} else {
newUrls.set(file, URL.createObjectURL(file))
} }
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
URL.revokeObjectURL(url)
}
})
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files]) }, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
imageUrlsRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
selectedImage &&
!Array.from(imageUrls.values()).includes(selectedImage)
) {
setSelectedImage(null)
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null if (files.length === 0) return null
return ( return (
<> <>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md"> <div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => { {files.map((file, index) => {
const imageUrl = file.type.startsWith("image/") const imageUrl = imageUrls.get(file) || null
? URL.createObjectURL(file)
: null
return ( return (
<div key={file.name + index} className="relative group"> <div key={file.name + index} className="relative group">
<div <div
@@ -40,9 +76,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
imageUrl && setSelectedImage(imageUrl) imageUrl && setSelectedImage(imageUrl)
} }
> >
{file.type.startsWith("image/") ? ( {file.type.startsWith("image/") && imageUrl ? (
<Image <Image
src={imageUrl!} src={imageUrl}
alt={file.name} alt={file.name}
width={80} width={80}
height={80} height={80}

View File

@@ -168,7 +168,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// Insert after cell0 if possible // Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]') const cell0 = currentRoot.querySelector('mxCell[id="0"]')
if (cell0 && cell0.nextSibling) { if (cell0?.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling) currentRoot.insertBefore(cell1, cell0.nextSibling)
} else { } else {
currentRoot.appendChild(cell1) currentRoot.appendChild(cell1)