Skip to content

Commit 609ac4e

Browse files
authored
feat(bubble): add waiting bubble for new AI agent requests (#20)
1 parent 170c5f5 commit 609ac4e

File tree

4 files changed

+128
-43
lines changed

4 files changed

+128
-43
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
node_modules
22
dist
33

4+
.idea
45
*.log
56
.DS_Store
67
.eslintcache

playground/src/Chat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export function Chat() {
4242
if (!backend) {
4343
throw new Error("Backend is not initialized");
4444
}
45-
const { messages, input, setMessages } = useChat(backend, initialMessages);
45+
const { messages, input, setMessages, isPending } = useChat(
46+
backend,
47+
initialMessages,
48+
);
4649

4750
const onClear = () => {
4851
setPrompt("");
@@ -57,6 +60,7 @@ export function Chat() {
5760
className="px-4 w-full max-w-full"
5861
messages={messages}
5962
background="right-solid"
63+
isPending={isPending}
6064
footer={
6165
<Button
6266
onClick={onClear}

src/bubble.tsx

Lines changed: 113 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ export interface BubbleProps
8989
* @default "solid"
9090
*/
9191
background?: "transparent" | "solid";
92+
/**
93+
* Custom pending content to display when pending is true.
94+
* @description If not provided, will use default dots animation.
95+
*/
96+
pending?: React.ReactNode;
97+
/**
98+
* Whether the bubble is in pending state.
99+
* @default false
100+
*/
101+
isPending?: boolean;
92102
}
93103

94104
export function Bubble({
@@ -97,10 +107,26 @@ export function Bubble({
97107
size,
98108
align,
99109
background = "solid",
110+
pending,
111+
isPending = false,
100112
...props
101113
}: BubbleProps) {
102114
const { isDark } = useTheme();
103115

116+
const defaultPending = (
117+
<div className="flex items-center space-x-1 py-1">
118+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
119+
<div
120+
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
121+
style={{ animationDelay: "0.1s" }}
122+
/>
123+
<div
124+
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
125+
style={{ animationDelay: "0.2s" }}
126+
/>
127+
</div>
128+
);
129+
104130
return (
105131
<div
106132
data-slot="bubble"
@@ -112,50 +138,55 @@ export function Bubble({
112138
align,
113139
background,
114140
}),
141+
pending && "flex items-center",
115142
),
116143
)}
117144
{...props}
118145
>
119-
<Markdown
120-
remarkPlugins={[remarkGfm, remarkMath]}
121-
components={{
122-
code(props) {
123-
const { children, className, ref: _ref, ...rest } = props;
124-
const match = /language-(\w+)/.exec(className || "");
125-
return match ? (
126-
<div className="w-full overflow-x-auto border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 rounded-lg">
127-
<SyntaxHighlighter
128-
{...rest}
129-
PreTag="div"
130-
language={match[1]}
131-
style={isDark ? vscDarkPlus : oneLight}
132-
customStyle={{
133-
background: "transparent",
134-
margin: 0,
135-
padding: "1rem",
136-
borderRadius: "0.5rem",
137-
overflowX: "auto",
138-
}}
139-
codeTagProps={{
140-
style: {
141-
fontFamily: "monospace",
142-
fontSize: "0.875rem",
143-
},
144-
}}
145-
>
146-
{String(children).replace(/\n$/, "")}
147-
</SyntaxHighlighter>
148-
</div>
149-
) : (
150-
<code {...rest} className={className}>
151-
{children}
152-
</code>
153-
);
154-
},
155-
}}
156-
>
157-
{text}
158-
</Markdown>
146+
{isPending ? (
147+
pending || defaultPending
148+
) : (
149+
<Markdown
150+
remarkPlugins={[remarkGfm, remarkMath]}
151+
components={{
152+
code(props) {
153+
const { children, className, ref: _ref, ...rest } = props;
154+
const match = /language-(\w+)/.exec(className || "");
155+
return match ? (
156+
<div className="w-full overflow-x-auto border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 rounded-lg">
157+
<SyntaxHighlighter
158+
{...rest}
159+
PreTag="div"
160+
language={match[1]}
161+
style={isDark ? vscDarkPlus : oneLight}
162+
customStyle={{
163+
background: "transparent",
164+
margin: 0,
165+
padding: "1rem",
166+
borderRadius: "0.5rem",
167+
overflowX: "auto",
168+
}}
169+
codeTagProps={{
170+
style: {
171+
fontFamily: "monospace",
172+
fontSize: "0.875rem",
173+
},
174+
}}
175+
>
176+
{String(children).replace(/\n$/, "")}
177+
</SyntaxHighlighter>
178+
</div>
179+
) : (
180+
<code {...rest} className={className}>
181+
{children}
182+
</code>
183+
);
184+
},
185+
}}
186+
>
187+
{text}
188+
</Markdown>
189+
)}
159190
</div>
160191
);
161192
}
@@ -202,13 +233,27 @@ export interface BubbleListProps extends React.ComponentProps<"div"> {
202233
* @default "right-solid"
203234
*/
204235
background?: "transparent" | "solid" | "left-solid" | "right-solid";
236+
isPending?: boolean;
237+
assistant?: {
238+
avatar?: AvatarProps;
239+
align?: "left" | "right";
240+
};
205241
footer?: React.ReactNode;
242+
pending?: React.ReactNode;
206243
}
207244

208245
export function BubbleList({
209246
className,
210247
background = "right-solid",
211248
footer,
249+
pending,
250+
assistant = {
251+
avatar: {
252+
text: "A",
253+
},
254+
align: "left",
255+
},
256+
isPending = true,
212257
...props
213258
}: BubbleListProps) {
214259
const { messages } = props;
@@ -222,7 +267,7 @@ export function BubbleList({
222267
block: "end",
223268
});
224269
}
225-
}, [messages]);
270+
}, [messages, isPending]);
226271

227272
return (
228273
<div
@@ -269,6 +314,33 @@ export function BubbleList({
269314
/>
270315
</div>
271316
))}
317+
{isPending && (
318+
<div
319+
key="pending"
320+
data-slot="bubble-item"
321+
className={twMerge(
322+
clsx(assistant?.align === "right" && "flex-row-reverse"),
323+
"flex items-start gap-2 w-full",
324+
)}
325+
>
326+
<Avatar className="flex-shrink-0" {...(assistant?.avatar || {})} />
327+
<Bubble
328+
isPending={isPending}
329+
pending={pending}
330+
text=""
331+
align={assistant?.align || "left"}
332+
background={
333+
(background === "left-solid" &&
334+
(assistant?.align || "left") === "left") ||
335+
(background === "right-solid" &&
336+
(assistant?.align || "left") === "right") ||
337+
background === "solid"
338+
? "solid"
339+
: "transparent"
340+
}
341+
/>
342+
</div>
343+
)}
272344
</div>
273345
{footer && (
274346
<div

src/utils/chat.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ export function useChat(
1010
input: (prompt: string, options?: InputOptions) => Promise<void>;
1111
on: <K extends EventTypes["type"]>(type: K, handler: Events[K]) => () => void;
1212
setMessages: React.Dispatch<React.SetStateAction<MessageParam[]>>;
13+
isPending: boolean;
1314
} {
1415
const [messages, setMessages] = useState<MessageParam[]>(initialMessages);
16+
const [isPending, setIsPending] = useState(false);
1517

1618
const input = async (prompt: string, options?: InputOptions) => {
19+
setIsPending(true);
1720
return backend.input(prompt, {
1821
messages,
1922
...options,
@@ -47,6 +50,7 @@ export function useChat(
4750
setMessages((prevMessages) => [...prevMessages, event.payload]);
4851
}),
4952
backend.on("error", (event) => {
53+
setIsPending(false);
5054
console.error("Error from backend:", event.payload.error);
5155
setMessages((prevMessages) => [
5256
...prevMessages,
@@ -58,7 +62,11 @@ export function useChat(
5862
},
5963
]);
6064
}),
65+
backend.on("finish", (event) => {
66+
setIsPending(false);
67+
}),
6168
backend.on("chunk", (event) => {
69+
setIsPending(false);
6270
setMessages((prev) => {
6371
const lastMessage = prev[prev.length - 1];
6472
if (lastMessage && lastMessage.role === "assistant") {
@@ -92,5 +100,5 @@ export function useChat(
92100
};
93101
}, [backend]);
94102

95-
return { messages, input, on, setMessages };
103+
return { messages, input, on, setMessages, isPending };
96104
}

0 commit comments

Comments
 (0)