story-kit: merge 178_story_fix_chat_textarea_input_lag

This commit is contained in:
Dave
2026-02-25 11:41:44 +00:00
parent fb29327cea
commit 71691a63ce
5 changed files with 696 additions and 388 deletions

View File

@@ -5,13 +5,15 @@ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { PipelineState } from "../api/client";
import { api, ChatWebSocket } from "../api/client";
import { useChatHistory } from "../hooks/useChatHistory";
import type { Message, ProviderConfig, ToolCall } from "../types";
import type { Message, ProviderConfig } from "../types";
import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader";
import { ChatInput } from "./ChatInput";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem";
import { StagePanel } from "./StagePanel";
const { useCallback, useEffect, useRef, useState } = React;
const { useCallback, useEffect, useMemo, useRef, useState } = React;
/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */
function ThinkingBlock({ text }: { text: string }) {
@@ -63,6 +65,37 @@ function ThinkingBlock({ text }: { text: string }) {
);
}
/** Streaming message renderer — stable component to avoid recreation on each render. */
function StreamingMessage({ content }: { content: string }) {
return (
<Markdown
components={{
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{content}
</Markdown>
);
}
const NARROW_BREAKPOINT = 900;
function formatToolActivity(toolName: string): string {
@@ -100,6 +133,16 @@ function formatToolActivity(toolName: string): string {
}
}
const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
const getContextWindowSize = (modelName: string): number => {
if (modelName.startsWith("claude-")) return 200000;
if (modelName.includes("llama3")) return 8192;
if (modelName.includes("qwen2.5")) return 32768;
if (modelName.includes("deepseek")) return 16384;
return 8192;
};
interface ChatProps {
projectPath: string;
onCloseProject: () => void;
@@ -107,7 +150,6 @@ interface ChatProps {
export function Chat({ projectPath, onCloseProject }: ChatProps) {
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [model, setModel] = useState("llama3.1");
const [enableTools, setEnableTools] = useState(true);
@@ -155,30 +197,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const lastScrollTopRef = useRef(0);
const userScrolledUpRef = useRef(false);
const pendingMessageRef = useRef<string>("");
const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
const getContextWindowSize = (modelName: string): number => {
if (modelName.startsWith("claude-")) return 200000;
if (modelName.includes("llama3")) return 8192;
if (modelName.includes("qwen2.5")) return 32768;
if (modelName.includes("deepseek")) return 16384;
return 8192;
};
const calculateContextUsage = (): {
used: number;
total: number;
percentage: number;
} => {
const contextUsage = useMemo(() => {
let totalTokens = 0;
totalTokens += 200;
for (const msg of messages) {
@@ -200,9 +226,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
total: contextWindow,
percentage,
};
};
const contextUsage = calculateContextUsage();
}, [messages, streamingContent, model]);
useEffect(() => {
api
@@ -371,10 +395,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}
}, [autoScrollKey, scrollToBottom]);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Auto-send queued message when loading ends
useEffect(() => {
if (pendingAutoSend) {
@@ -415,21 +435,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}
};
const sendMessage = async (messageOverride?: string) => {
const messageToSend = messageOverride ?? input;
if (!messageToSend.trim()) return;
const sendMessage = async (messageText: string) => {
if (!messageText.trim()) return;
// Agent is busy — queue the message instead of dropping it
if (loading) {
const newItem = {
id: String(queueIdCounterRef.current++),
text: messageToSend,
text: messageText,
};
queuedMessagesRef.current = [...queuedMessagesRef.current, newItem];
setQueuedMessages([...queuedMessagesRef.current]);
if (!messageOverride || messageOverride === input) {
setInput("");
}
return;
}
@@ -437,19 +453,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
if (!isClaudeCode && model.startsWith("claude-")) {
const hasKey = await api.getAnthropicApiKeyExists();
if (!hasKey) {
pendingMessageRef.current = messageToSend;
pendingMessageRef.current = messageText;
setShowApiKeyDialog(true);
return;
}
}
const userMsg: Message = { role: "user", content: messageToSend };
const userMsg: Message = { role: "user", content: messageText };
const newHistory = [...messages, userMsg];
setMessages(newHistory);
if (!messageOverride || messageOverride === input) {
setInput("");
}
setLoading(true);
setStreamingContent("");
setStreamingThinking("");
@@ -536,6 +549,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}
};
const handleRemoveQueuedMessage = useCallback((id: string) => {
queuedMessagesRef.current = queuedMessagesRef.current.filter(
(item) => item.id !== id,
);
setQueuedMessages([...queuedMessagesRef.current]);
}, []);
return (
<div
className="chat-container"
@@ -667,158 +687,10 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div>
)}
{messages.map((msg: Message, idx: number) => (
<div
<MessageItem
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: msg.role === "user" ? "10px 16px" : "0",
borderRadius: msg.role === "user" ? "20px" : "0",
background:
msg.role === "user"
? "#2f2f2f"
: msg.role === "tool"
? "#222"
: "transparent",
color: "#ececec",
border: msg.role === "tool" ? "1px solid #333" : "none",
fontFamily: msg.role === "tool" ? "monospace" : "inherit",
fontSize: msg.role === "tool" ? "0.85em" : "1em",
fontWeight: "500",
whiteSpace: msg.role === "tool" ? "pre-wrap" : "normal",
lineHeight: "1.6",
}}
>
{msg.role === "user" ? (
msg.content
) : msg.role === "tool" ? (
<details style={{ cursor: "pointer" }}>
<summary
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "8px",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span style={{ fontSize: "0.8em" }}></span>
<span>
Tool Output
{msg.tool_call_id && ` (${msg.tool_call_id})`}
</span>
</summary>
<pre
style={{
maxHeight: "300px",
overflow: "auto",
margin: 0,
padding: "8px",
background: "#1a1a1a",
borderRadius: "4px",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</pre>
</details>
) : (
<div className="markdown-body">
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(
className || "",
);
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{msg.content}
</Markdown>
</div>
)}
{msg.tool_calls && (
<div
style={{
marginTop: "12px",
fontSize: "0.85em",
color: "#aaa",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{msg.tool_calls.map((tc: ToolCall, i: number) => {
let argsSummary = "";
try {
const args = JSON.parse(tc.function.arguments);
const firstKey = Object.keys(args)[0];
if (firstKey && args[firstKey]) {
argsSummary = String(args[firstKey]);
if (argsSummary.length > 50) {
argsSummary = `${argsSummary.substring(0, 47)}...`;
}
}
} catch (_e) {
// ignore
}
return (
<div
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontFamily: "monospace",
}}
>
<span style={{ color: "#888" }}></span>
<span
style={{
background: "#333",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{tc.function.name}
{argsSummary && `(${argsSummary})`}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
msg={msg}
/>
))}
{loading && streamingThinking && (
<ThinkingBlock text={streamingThinking} />
@@ -847,34 +719,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}}
>
<div className="markdown-body">
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(
className || "",
);
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{streamingContent}
</Markdown>
<StreamingMessage content={streamingContent} />
</div>
</div>
</div>
@@ -947,176 +792,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
)}
{/* Chat input pinned at bottom of left column */}
<div
style={{
padding: "24px",
background: "#171717",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
maxWidth: "768px",
width: "100%",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{/* Queued message indicators */}
{queuedMessages.map(({ id, text }) => (
<div
key={id}
data-testid="queued-message-indicator"
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "#1e1e1e",
border: "1px solid #3a3a3a",
borderRadius: "12px",
fontSize: "0.875rem",
}}
>
<span
style={{
color: "#666",
flexShrink: 0,
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
>
Queued
</span>
<span
style={{
color: "#888",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{text}
</span>
<button
type="button"
title="Edit queued message"
onClick={() => {
setInput(text);
queuedMessagesRef.current =
queuedMessagesRef.current.filter(
(item) => item.id !== id,
);
setQueuedMessages([...queuedMessagesRef.current]);
inputRef.current?.focus();
}}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 6px",
fontSize: "0.8rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
Edit
</button>
<button
type="button"
title="Cancel queued message"
onClick={() => {
queuedMessagesRef.current =
queuedMessagesRef.current.filter(
(item) => item.id !== id,
);
setQueuedMessages([...queuedMessagesRef.current]);
}}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 4px",
fontSize: "0.875rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
</button>
</div>
))}
{/* Input row */}
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="Send a message..."
rows={1}
style={{
flex: 1,
padding: "14px 20px",
borderRadius: "24px",
border: "1px solid #333",
outline: "none",
fontSize: "1rem",
fontWeight: "500",
background: "#2f2f2f",
color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
resize: "none",
overflowY: "auto",
fontFamily: "inherit",
}}
/>
<button
type="button"
onClick={
loading && !input.trim()
? cancelGeneration
: () => sendMessage()
}
disabled={!loading && !input.trim()}
style={{
background: "#ececec",
color: "black",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{loading && !input.trim() ? "■" : "↑"}
</button>
</div>
</div>
</div>
<ChatInput
loading={loading}
queuedMessages={queuedMessages}
onSubmit={sendMessage}
onCancel={cancelGeneration}
onRemoveQueuedMessage={handleRemoveQueuedMessage}
/>
</div>
{/* Right column: panels independently scrollable */}

View File

@@ -0,0 +1,202 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatInput } from "./ChatInput";
describe("ChatInput component (Story 178 AC1)", () => {
it("renders a textarea with Send a message... placeholder", () => {
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
expect(textarea.tagName.toLowerCase()).toBe("textarea");
});
it("manages input state internally — typing updates value without calling onSubmit", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect((textarea as HTMLTextAreaElement).value).toBe("hello world");
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the input text on Enter key press", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "test message" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
});
expect(onSubmit).toHaveBeenCalledWith("test message");
});
it("clears input after submitting", async () => {
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
});
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
it("does not submit on Shift+Enter", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "multiline" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
});
expect(onSubmit).not.toHaveBeenCalled();
expect((textarea as HTMLTextAreaElement).value).toBe("multiline");
});
it("calls onCancel when stop button is clicked while loading with empty input", async () => {
const onCancel = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={onCancel}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const stopButton = screen.getByRole("button", { name: "■" });
await act(async () => {
fireEvent.click(stopButton);
});
expect(onCancel).toHaveBeenCalled();
});
it("renders queued message indicators", () => {
render(
<ChatInput
loading={true}
queuedMessages={[
{ id: "1", text: "first message" },
{ id: "2", text: "second message" },
]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const indicators = screen.getAllByTestId("queued-message-indicator");
expect(indicators).toHaveLength(2);
expect(indicators[0]).toHaveTextContent("first message");
expect(indicators[1]).toHaveTextContent("second message");
});
it("calls onRemoveQueuedMessage when cancel button is clicked", async () => {
const onRemove = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[{ id: "q1", text: "to remove" }]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={onRemove}
/>,
);
const cancelBtn = screen.getByTitle("Cancel queued message");
await act(async () => {
fireEvent.click(cancelBtn);
});
expect(onRemove).toHaveBeenCalledWith("q1");
});
it("edit button restores queued message text to input and removes from queue", async () => {
const onRemove = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[{ id: "q1", text: "edit me back" }]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={onRemove}
/>,
);
const editBtn = screen.getByTitle("Edit queued message");
await act(async () => {
fireEvent.click(editBtn);
});
const textarea = screen.getByPlaceholderText("Send a message...");
expect((textarea as HTMLTextAreaElement).value).toBe("edit me back");
expect(onRemove).toHaveBeenCalledWith("q1");
});
});

View File

@@ -0,0 +1,191 @@
import * as React from "react";
const { useEffect, useRef, useState } = React;
interface ChatInputProps {
loading: boolean;
queuedMessages: { id: string; text: string }[];
onSubmit: (message: string) => void;
onCancel: () => void;
onRemoveQueuedMessage: (id: string) => void;
}
export function ChatInput({
loading,
queuedMessages,
onSubmit,
onCancel,
onRemoveQueuedMessage,
}: ChatInputProps) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = () => {
if (!input.trim()) return;
onSubmit(input);
setInput("");
};
return (
<div
style={{
padding: "24px",
background: "#171717",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
maxWidth: "768px",
width: "100%",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{/* Queued message indicators */}
{queuedMessages.map(({ id, text }) => (
<div
key={id}
data-testid="queued-message-indicator"
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "#1e1e1e",
border: "1px solid #3a3a3a",
borderRadius: "12px",
fontSize: "0.875rem",
}}
>
<span
style={{
color: "#666",
flexShrink: 0,
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
>
Queued
</span>
<span
style={{
color: "#888",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{text}
</span>
<button
type="button"
title="Edit queued message"
onClick={() => {
setInput(text);
onRemoveQueuedMessage(id);
inputRef.current?.focus();
}}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 6px",
fontSize: "0.8rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
Edit
</button>
<button
type="button"
title="Cancel queued message"
onClick={() => onRemoveQueuedMessage(id)}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 4px",
fontSize: "0.875rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
</button>
</div>
))}
{/* Input row */}
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Send a message..."
rows={1}
style={{
flex: 1,
padding: "14px 20px",
borderRadius: "24px",
border: "1px solid #333",
outline: "none",
fontSize: "1rem",
fontWeight: "500",
background: "#2f2f2f",
color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
resize: "none",
overflowY: "auto",
fontFamily: "inherit",
}}
/>
<button
type="button"
onClick={loading && !input.trim() ? onCancel : handleSubmit}
disabled={!loading && !input.trim()}
style={{
background: "#ececec",
color: "black",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{loading && !input.trim() ? "■" : "↑"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MessageItem } from "./MessageItem";
describe("MessageItem component (Story 178 AC3)", () => {
it("renders user message as a bubble", () => {
render(<MessageItem msg={{ role: "user", content: "Hello there!" }} />);
expect(screen.getByText("Hello there!")).toBeInTheDocument();
});
it("renders assistant message with markdown-body class", () => {
render(
<MessageItem
msg={{ role: "assistant", content: "Here is my response." }}
/>,
);
expect(screen.getByText("Here is my response.")).toBeInTheDocument();
const text = screen.getByText("Here is my response.");
expect(text.closest(".markdown-body")).toBeTruthy();
});
it("renders tool message as collapsible details", () => {
render(
<MessageItem
msg={{
role: "tool",
content: "tool output content",
tool_call_id: "toolu_1",
}}
/>,
);
expect(screen.getByText(/Tool Output/)).toBeInTheDocument();
});
it("renders tool call badges for assistant messages with tool_calls", () => {
render(
<MessageItem
msg={{
role: "assistant",
content: "I will read the file.",
tool_calls: [
{
id: "toolu_1",
type: "function",
function: {
name: "Read",
arguments: '{"file_path":"src/main.rs"}',
},
},
],
}}
/>,
);
expect(screen.getByText("I will read the file.")).toBeInTheDocument();
expect(screen.getByText("Read(src/main.rs)")).toBeInTheDocument();
});
it("is wrapped in React.memo (has displayName or $$typeof memo)", () => {
// React.memo wraps the component — verify the export is memoized
// by checking that the component has a memo wrapper
const { type } = { type: MessageItem };
// React.memo returns an object with $$typeof === Symbol(react.memo)
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
expect((type as any).$$typeof).toBeDefined();
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
const typeofStr = String((type as any).$$typeof);
expect(typeofStr).toContain("memo");
});
});

View File

@@ -0,0 +1,160 @@
import * as React from "react";
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { Message, ToolCall } from "../types";
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
function CodeBlock({ className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
interface MessageItemProps {
msg: Message;
}
function MessageItemInner({ msg }: MessageItemProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: msg.role === "user" ? "10px 16px" : "0",
borderRadius: msg.role === "user" ? "20px" : "0",
background:
msg.role === "user"
? "#2f2f2f"
: msg.role === "tool"
? "#222"
: "transparent",
color: "#ececec",
border: msg.role === "tool" ? "1px solid #333" : "none",
fontFamily: msg.role === "tool" ? "monospace" : "inherit",
fontSize: msg.role === "tool" ? "0.85em" : "1em",
fontWeight: "500",
whiteSpace: msg.role === "tool" ? "pre-wrap" : "normal",
lineHeight: "1.6",
}}
>
{msg.role === "user" ? (
msg.content
) : msg.role === "tool" ? (
<details style={{ cursor: "pointer" }}>
<summary
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "8px",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span style={{ fontSize: "0.8em" }}></span>
<span>
Tool Output
{msg.tool_call_id && ` (${msg.tool_call_id})`}
</span>
</summary>
<pre
style={{
maxHeight: "300px",
overflow: "auto",
margin: 0,
padding: "8px",
background: "#1a1a1a",
borderRadius: "4px",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</pre>
</details>
) : (
<div className="markdown-body">
<Markdown components={{ code: CodeBlock }}>{msg.content}</Markdown>
</div>
)}
{msg.tool_calls && (
<div
style={{
marginTop: "12px",
fontSize: "0.85em",
color: "#aaa",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{msg.tool_calls.map((tc: ToolCall, i: number) => {
let argsSummary = "";
try {
const args = JSON.parse(tc.function.arguments);
const firstKey = Object.keys(args)[0];
if (firstKey && args[firstKey]) {
argsSummary = String(args[firstKey]);
if (argsSummary.length > 50) {
argsSummary = `${argsSummary.substring(0, 47)}...`;
}
}
} catch (_e) {
// ignore
}
return (
<div
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontFamily: "monospace",
}}
>
<span style={{ color: "#888" }}></span>
<span
style={{
background: "#333",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{tc.function.name}
{argsSummary && `(${argsSummary})`}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export const MessageItem = React.memo(MessageItemInner);