Files
huskies/frontend/src/components/ChatMessageList.tsx
T

279 lines
6.7 KiB
TypeScript

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 { WizardStateData } from "../api/client";
import type { Message } from "../types";
import { MessageItem } from "./MessageItem";
import SetupWizard from "./SetupWizard";
const { useEffect, useRef } = React;
/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */
export function ThinkingBlock({ text }: { text: string }) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [text]);
return (
<div
data-testid="chat-thinking-block"
ref={scrollRef}
style={{
maxHeight: "96px",
overflowY: "auto",
background: "#161b22",
border: "1px solid #2d333b",
borderRadius: "6px",
padding: "6px 10px",
fontSize: "0.78em",
fontFamily: "monospace",
color: "#6e7681",
fontStyle: "italic",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.4",
marginBottom: "8px",
}}
>
<span
style={{
display: "block",
fontSize: "0.8em",
color: "#444c56",
marginBottom: "4px",
fontStyle: "normal",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
thinking
</span>
{text}
</div>
);
}
/** Streaming message renderer — stable component to avoid recreation on each render. */
export 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>
);
}
interface ChatMessageListProps {
messages: Message[];
loading: boolean;
streamingContent: string;
streamingThinking: string;
activityStatus: string | null;
wizardState: WizardStateData | null;
setWizardState: React.Dispatch<React.SetStateAction<WizardStateData | null>>;
needsOnboarding: boolean;
setNeedsOnboarding: React.Dispatch<React.SetStateAction<boolean>>;
sendMessage: (text: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
onboardingTriggeredRef: React.MutableRefObject<boolean>;
}
export function ChatMessageList({
messages,
loading,
streamingContent,
streamingThinking,
activityStatus,
wizardState,
setWizardState,
needsOnboarding,
setNeedsOnboarding,
sendMessage,
scrollContainerRef,
messagesEndRef,
onScroll,
onboardingTriggeredRef,
}: ChatMessageListProps) {
return (
<div
ref={scrollContainerRef}
onScroll={onScroll}
style={{
flex: 1,
overflowY: "auto",
padding: "20px 0",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<div
style={{
maxWidth: "768px",
margin: "0 auto",
width: "100%",
padding: "0 24px",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
{wizardState &&
!wizardState.completed &&
messages.length === 0 &&
!loading && (
<SetupWizard
wizardState={wizardState}
onWizardUpdate={setWizardState}
sendMessage={sendMessage}
/>
)}
{needsOnboarding &&
!wizardState &&
messages.length === 0 &&
!loading && (
<div
data-testid="onboarding-welcome"
style={{
padding: "24px",
borderRadius: "12px",
background: "#1c2a1c",
border: "1px solid #2d4a2d",
marginBottom: "8px",
}}
>
<h3
style={{
margin: "0 0 8px 0",
color: "#a0d4a0",
fontSize: "1.1rem",
}}
>
Welcome to Huskies
</h3>
<p
style={{
margin: "0 0 16px 0",
color: "#ccc",
lineHeight: 1.5,
}}
>
This project needs to be set up before you can start writing
stories. The agent will guide you through configuring your
project goals and tech stack.
</p>
<button
type="button"
data-testid="onboarding-start-button"
onClick={() => {
if (onboardingTriggeredRef.current) return;
onboardingTriggeredRef.current = true;
setNeedsOnboarding(false);
sendMessage(
"I just created a new project. Help me set it up.",
);
}}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#a0d4a0",
color: "#1a1a1a",
cursor: "pointer",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
Start Project Setup
</button>
</div>
)}
{messages.map((msg: Message, idx: number) => (
<MessageItem
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
msg={msg}
/>
))}
{loading && streamingThinking && (
<ThinkingBlock text={streamingThinking} />
)}
{loading && streamingContent && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: "0",
borderRadius: "0",
background: "transparent",
color: "#ececec",
border: "none",
fontFamily: "inherit",
fontSize: "1em",
fontWeight: "500",
whiteSpace: "normal",
lineHeight: "1.6",
}}
>
<div className="markdown-body">
<StreamingMessage content={streamingContent} />
</div>
</div>
</div>
)}
{loading &&
(activityStatus != null ||
(!streamingContent && !streamingThinking)) && (
<div
data-testid="activity-indicator"
style={{
alignSelf: "flex-start",
color: "#888",
fontSize: "0.9em",
marginTop: "10px",
}}
>
<span className="pulse">{activityStatus ?? "Thinking..."}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
);
}