279 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|