story-kit: merge 163_story_remove_bubble_styling_from_streaming_chat_messages

This commit is contained in:
Dave
2026-02-24 17:51:55 +00:00
parent 366e8cb7df
commit ee8be90ce5
2 changed files with 202 additions and 37 deletions

View File

@@ -892,3 +892,164 @@ describe("Chat message queue (Story 155)", () => {
}); });
}); });
}); });
describe("Remove bubble styling from streaming messages (Story 163)", () => {
beforeEach(() => {
capturedWsHandlers = null;
setupMocks();
});
it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// Send a message to put chat into loading state
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Hello" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Simulate streaming tokens arriving
act(() => {
capturedWsHandlers?.onToken("Streaming response text");
});
// Find the streaming message container (the inner div wrapping the Markdown)
const streamingText = await screen.findByText("Streaming response text");
// The markdown-body wrapper is the parent, and the styled div is its parent
const styledDiv = streamingText.closest(".markdown-body")
?.parentElement as HTMLElement;
expect(styledDiv).toBeTruthy();
const styleAttr = styledDiv.getAttribute("style") ?? "";
expect(styleAttr).toContain("background: transparent");
expect(styleAttr).toContain("padding: 0px");
expect(styleAttr).toContain("border-radius: 0px");
expect(styleAttr).toContain("max-width: 100%");
});
it("AC1: streaming message wraps Markdown in markdown-body class", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Hello" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
act(() => {
capturedWsHandlers?.onToken("Some markdown content");
});
const streamingText = await screen.findByText("Some markdown content");
const markdownBody = streamingText.closest(".markdown-body");
expect(markdownBody).toBeTruthy();
});
it("AC2: no visual change when streaming ends and message transitions to completed", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// Send a message to start streaming
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Hello" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Simulate streaming tokens
act(() => {
capturedWsHandlers?.onToken("Final response");
});
// Capture streaming message style attribute
const streamingText = await screen.findByText("Final response");
const streamingStyledDiv = streamingText.closest(".markdown-body")
?.parentElement as HTMLElement;
const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? "";
// Transition: onUpdate completes the message
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Final response" },
]);
});
// Find the completed message — it should have the same styling
const completedText = await screen.findByText("Final response");
const completedMarkdownBody = completedText.closest(".markdown-body");
const completedStyledDiv =
completedMarkdownBody?.parentElement as HTMLElement;
expect(completedStyledDiv).toBeTruthy();
const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? "";
// Both streaming and completed use transparent bg, 0 padding, 0 border-radius
expect(completedStyleAttr).toContain("background: transparent");
expect(completedStyleAttr).toContain("padding: 0px");
expect(completedStyleAttr).toContain("border-radius: 0px");
expect(streamingStyleAttr).toContain("background: transparent");
expect(streamingStyleAttr).toContain("padding: 0px");
expect(streamingStyleAttr).toContain("border-radius: 0px");
// Both have the markdown-body class wrapper
expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy();
});
it("AC3: completed assistant messages retain transparent background and no border-radius", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello there!" },
]);
});
const assistantText = await screen.findByText("Hello there!");
const markdownBody = assistantText.closest(".markdown-body");
const styledDiv = markdownBody?.parentElement as HTMLElement;
expect(styledDiv).toBeTruthy();
const styleAttr = styledDiv.getAttribute("style") ?? "";
expect(styleAttr).toContain("background: transparent");
expect(styleAttr).toContain("padding: 0px");
expect(styleAttr).toContain("border-radius: 0px");
expect(styleAttr).toContain("max-width: 100%");
});
it("AC3: completed user messages still have their bubble styling", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "I am a user message" },
{ role: "assistant", content: "I am a response" },
]);
});
// findByText returns the styled div itself for user messages (text is direct child)
const userStyledDiv = await screen.findByText("I am a user message");
const styleAttr = userStyledDiv.getAttribute("style") ?? "";
// User messages retain bubble: distinct background, padding, rounded corners
expect(styleAttr).toContain("padding: 10px 16px");
expect(styleAttr).toContain("border-radius: 20px");
expect(styleAttr).not.toContain("background: transparent");
});
});

View File

@@ -761,45 +761,49 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
> >
<div <div
style={{ style={{
maxWidth: "85%", maxWidth: "100%",
padding: "16px 20px", padding: "0",
borderRadius: "12px", borderRadius: "0",
background: "#262626", background: "transparent",
color: "#fff", color: "#ececec",
border: "1px solid #404040", border: "none",
fontFamily: "system-ui, -apple-system, sans-serif", fontFamily: "inherit",
fontSize: "0.95rem", fontSize: "1em",
fontWeight: 400, fontWeight: "500",
whiteSpace: "pre-wrap", whiteSpace: "normal",
lineHeight: 1.6, lineHeight: "1.6",
}} }}
> >
<Markdown <div className="markdown-body">
components={{ <Markdown
// eslint-disable-next-line @typescript-eslint/no-explicit-any components={{
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props // eslint-disable-next-line @typescript-eslint/no-explicit-any
code: ({ className, children, ...props }: any) => { // biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
const match = /language-(\w+)/.exec(className || ""); code: ({ className, children, ...props }: any) => {
const isInline = !className; const match = /language-(\w+)/.exec(
return !isInline && match ? ( className || "",
<SyntaxHighlighter );
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible const isInline = !className;
style={oneDark as any} return !isInline && match ? (
language={match[1]} <SyntaxHighlighter
PreTag="div" // biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
> style={oneDark as any}
{String(children).replace(/\n$/, "")} language={match[1]}
</SyntaxHighlighter> PreTag="div"
) : ( >
<code className={className} {...props}> {String(children).replace(/\n$/, "")}
{children} </SyntaxHighlighter>
</code> ) : (
); <code className={className} {...props}>
}, {children}
}} </code>
> );
{streamingContent} },
</Markdown> }}
>
{streamingContent}
</Markdown>
</div>
</div> </div>
</div> </div>
)} )}