Skip to content

Commit 2312752

Browse files
authored
custom streamdown components for wave ai (#2404)
much nicer markdown display for assistant messages. lots of fixes for code components.
1 parent f9b3761 commit 2312752

10 files changed

Lines changed: 413 additions & 55 deletions

File tree

frontend/app/aipanel/aimessage.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { WaveStreamdown } from "@/app/element/streamdown";
45
import { cn } from "@/util/util";
6+
import { useAtomValue } from "jotai";
57
import { memo } from "react";
6-
import { Streamdown } from "streamdown";
78
import { getFileIcon } from "./ai-utils";
89
import { WaveUIMessage, WaveUIMessagePart } from "./aitypes";
10+
import { WaveAIModel } from "./waveai-model";
911

1012
const AIThinking = memo(() => (
1113
<div className="flex items-center gap-2">
@@ -72,30 +74,21 @@ interface AIMessagePartProps {
7274
}
7375

7476
const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => {
77+
const model = WaveAIModel.getInstance();
78+
7579
if (part.type === "text") {
7680
const content = part.text ?? "";
7781

7882
if (role === "user") {
7983
return <div className="whitespace-pre-wrap break-words">{content}</div>;
8084
} else {
8185
return (
82-
<Streamdown
86+
<WaveStreamdown
87+
text={content}
8388
parseIncompleteMarkdown={isStreaming}
84-
className="markdown-content text-gray-100"
85-
shikiTheme={["github-dark", "github-dark"]}
86-
controls={{
87-
code: true,
88-
table: true,
89-
mermaid: true,
90-
}}
91-
mermaidConfig={{
92-
theme: "dark",
93-
darkMode: true,
94-
}}
95-
defaultOrigin="http://localhost"
96-
>
97-
{content}
98-
</Streamdown>
89+
className="text-gray-100"
90+
codeBlockMaxWidthAtom={model.codeBlockMaxWidth}
91+
/>
9992
);
10093
}
10194
}
@@ -139,9 +132,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
139132
<div
140133
className={cn(
141134
"px-2 py-2 rounded-lg",
142-
message.role === "user"
143-
? "bg-accent-800 text-white max-w-[calc(100%-20px)]"
144-
: "bg-gray-800 text-gray-100"
135+
message.role === "user" ? "bg-accent-800 text-white max-w-[calc(100%-20px)]" : null
145136
)}
146137
>
147138
{showThinkingOnly ? (

frontend/app/aipanel/aipanel.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
3434
const [isDragOver, setIsDragOver] = useState(false);
3535
const [isLoadingChat, setIsLoadingChat] = useState(true);
3636
const model = WaveAIModel.getInstance();
37+
const containerRef = useRef<HTMLDivElement>(null);
3738
const errorMessage = jotai.useAtomValue(model.errorMessage);
3839
const realMessageRef = useRef<AIMessage>(null);
3940
const inputRef = useRef<AIPanelInputRef>(null);
@@ -104,10 +105,32 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
104105
const messages = await model.loadChat();
105106
setMessages(messages as any);
106107
setIsLoadingChat(false);
108+
setTimeout(() => {
109+
model.scrollToBottom();
110+
}, 100);
107111
};
108112
loadMessages();
109113
}, [model, setMessages]);
110114

115+
useEffect(() => {
116+
const updateWidth = () => {
117+
if (containerRef.current) {
118+
globalStore.set(model.containerWidth, containerRef.current.offsetWidth);
119+
}
120+
};
121+
122+
updateWidth();
123+
124+
const resizeObserver = new ResizeObserver(updateWidth);
125+
if (containerRef.current) {
126+
resizeObserver.observe(containerRef.current);
127+
}
128+
129+
return () => {
130+
resizeObserver.disconnect();
131+
};
132+
}, [model]);
133+
111134
useEffect(() => {
112135
model.ensureRateLimitSet();
113136
}, [model]);
@@ -279,6 +302,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
279302

280303
return (
281304
<div
305+
ref={containerRef}
282306
data-waveai-panel="true"
283307
className={cn(
284308
"bg-gray-900 flex flex-col relative h-[calc(100%-4px)] mt-1",

frontend/app/aipanel/aipanelmessages.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
55
import { useAtomValue } from "jotai";
66
import { memo, useEffect, useRef } from "react";
77
import { AIMessage } from "./aimessage";
8+
import { WaveAIModel } from "./waveai-model";
89

910
const AIWelcomeMessage = memo(() => {
1011
return (
@@ -25,6 +26,7 @@ interface AIPanelMessagesProps {
2526
}
2627

2728
export const AIPanelMessages = memo(({ messages, status, isLoadingChat }: AIPanelMessagesProps) => {
29+
const model = WaveAIModel.getInstance();
2830
const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
2931
const messagesEndRef = useRef<HTMLDivElement>(null);
3032
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -37,6 +39,10 @@ export const AIPanelMessages = memo(({ messages, status, isLoadingChat }: AIPane
3739
}
3840
};
3941

42+
useEffect(() => {
43+
model.registerScrollToBottom(scrollToBottom);
44+
}, [model]);
45+
4046
useEffect(() => {
4147
scrollToBottom();
4248
}, [messages]);

frontend/app/aipanel/waveai-model.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ export interface DroppedFile {
2424
export class WaveAIModel {
2525
private static instance: WaveAIModel | null = null;
2626
private inputRef: React.RefObject<AIPanelInputRef> | null = null;
27+
private scrollToBottomCallback: (() => void) | null = null;
2728

2829
widgetAccessAtom!: jotai.Atom<boolean>;
2930
droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]);
3031
chatId!: jotai.PrimitiveAtom<string>;
3132
errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>;
3233
modelAtom!: jotai.Atom<string>;
34+
containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0);
35+
codeBlockMaxWidth!: jotai.Atom<number>;
3336

3437
private constructor() {
3538
const tabId = globalStore.get(atoms.staticTabId);
@@ -58,6 +61,11 @@ export class WaveAIModel {
5861
const value = get(widgetAccessMetaAtom);
5962
return value ?? true;
6063
});
64+
65+
this.codeBlockMaxWidth = jotai.atom((get) => {
66+
const width = get(this.containerWidth);
67+
return width > 0 ? width - 35 : 0;
68+
});
6169
}
6270

6371
static getInstance(): WaveAIModel {
@@ -140,6 +148,14 @@ export class WaveAIModel {
140148
this.inputRef = ref;
141149
}
142150

151+
registerScrollToBottom(callback: () => void) {
152+
this.scrollToBottomCallback = callback;
153+
}
154+
155+
scrollToBottom() {
156+
this.scrollToBottomCallback?.();
157+
}
158+
143159
focusInput() {
144160
if (!WorkspaceLayoutModel.getInstance().getAIPanelVisible()) {
145161
WorkspaceLayoutModel.getInstance().setAIPanelVisible(true);

0 commit comments

Comments
 (0)