@houston-ai/chat 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @houston-ai/chat
2
+
3
+ Full-featured AI chat interface. Streaming markdown, thinking blocks, tool activity, prompt input -- one component or pick individual pieces.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @houston-ai/chat
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { ChatPanel } from "@houston-ai/chat"
15
+ import "@houston-ai/chat/src/styles.css"
16
+
17
+ <ChatPanel
18
+ sessionKey={session.id}
19
+ feedItems={feedItems}
20
+ onSend={(text) => sendMessage(text)}
21
+ onStop={() => stopSession()}
22
+ isLoading={isStreaming}
23
+ />
24
+ ```
25
+
26
+ ## Exports
27
+
28
+ **Top-level:** ChatPanel, ChatInput, ToolActivity, ToolsAndCards, Typewriter, feedItemsToMessages
29
+
30
+ **AI Elements:** Conversation, ConversationContent, ConversationScrollButton, Message, MessageContent, MessageResponse, MessageToolbar, Reasoning, ReasoningTrigger, ReasoningContent, PromptInput (with 30+ sub-components), Shimmer, Suggestions
31
+
32
+ **Types:** FeedItem, RunStatus, ChatMessage, ToolEntry
33
+
34
+ ## How it works
35
+
36
+ `ChatPanel` accepts an array of `FeedItem` discriminated unions (user messages, assistant text, thinking, tool calls, tool results, final results) and renders the full conversation. Status is derived automatically from the feed, or you can override it.
37
+
38
+ The AI Elements are composable -- use `ChatPanel` for the batteries-included experience, or build your own layout with the primitives.
39
+
40
+ ## Peer Dependencies
41
+
42
+ - React 19+
43
+ - @houston-ai/core
44
+
45
+ ---
46
+
47
+ Part of [Houston](../../README.md).
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@houston-ai/chat",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src"
9
+ ],
10
+ "peerDependencies": {
11
+ "react": "^19.0.0",
12
+ "react-dom": "^19.0.0",
13
+ "@houston-ai/core": "workspace:*"
14
+ },
15
+ "dependencies": {
16
+ "ai": "^6.0.116",
17
+ "@radix-ui/react-use-controllable-state": "^1.2.2",
18
+ "framer-motion": "^12.38.0",
19
+ "motion": "^12.38.0",
20
+ "lucide-react": "^0.577.0",
21
+ "streamdown": "^2.5.0",
22
+ "@streamdown/cjk": "^1.0.3",
23
+ "@streamdown/code": "^1.1.1",
24
+ "@streamdown/math": "^1.0.2",
25
+ "@streamdown/mermaid": "^1.0.2",
26
+ "use-stick-to-bottom": "^1.1.3",
27
+ "marked": "^17.0.4",
28
+ "shiki": "^4.0.2",
29
+ "nanoid": "^5.1.7"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit"
33
+ }
34
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { Button } from "@houston-ai/core";
4
+ import { cn } from "@houston-ai/core";
5
+ import type { UIMessage } from "ai";
6
+ import { ArrowDownIcon, DownloadIcon } from "lucide-react";
7
+ import type { ComponentProps } from "react";
8
+ import { useCallback } from "react";
9
+ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
10
+
11
+ export type ConversationProps = ComponentProps<typeof StickToBottom>;
12
+
13
+ export const Conversation = ({ className, ...props }: ConversationProps) => (
14
+ <StickToBottom
15
+ className={cn("relative flex-1 overflow-y-hidden", className)}
16
+ initial="smooth"
17
+ resize="smooth"
18
+ role="log"
19
+ {...props}
20
+ />
21
+ );
22
+
23
+ export type ConversationContentProps = ComponentProps<
24
+ typeof StickToBottom.Content
25
+ >;
26
+
27
+ export const ConversationContent = ({
28
+ className,
29
+ ...props
30
+ }: ConversationContentProps) => (
31
+ <StickToBottom.Content
32
+ className={cn("flex flex-col gap-8 p-4", className)}
33
+ {...props}
34
+ />
35
+ );
36
+
37
+ export type ConversationEmptyStateProps = ComponentProps<"div"> & {
38
+ title?: string;
39
+ description?: string;
40
+ icon?: React.ReactNode;
41
+ };
42
+
43
+ export const ConversationEmptyState = ({
44
+ className,
45
+ title = "No messages yet",
46
+ description = "Start a conversation to see messages here",
47
+ icon,
48
+ children,
49
+ ...props
50
+ }: ConversationEmptyStateProps) => (
51
+ <div
52
+ className={cn(
53
+ "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
54
+ className
55
+ )}
56
+ {...props}
57
+ >
58
+ {children ?? (
59
+ <>
60
+ {icon && <div className="text-muted-foreground">{icon}</div>}
61
+ <div className="space-y-1">
62
+ <h3 className="font-medium text-sm">{title}</h3>
63
+ {description && (
64
+ <p className="text-muted-foreground text-sm">{description}</p>
65
+ )}
66
+ </div>
67
+ </>
68
+ )}
69
+ </div>
70
+ );
71
+
72
+ export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
73
+
74
+ export const ConversationScrollButton = ({
75
+ className,
76
+ ...props
77
+ }: ConversationScrollButtonProps) => {
78
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
79
+
80
+ const handleScrollToBottom = useCallback(() => {
81
+ scrollToBottom();
82
+ }, [scrollToBottom]);
83
+
84
+ return (
85
+ !isAtBottom && (
86
+ <Button
87
+ className={cn(
88
+ "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
89
+ className
90
+ )}
91
+ onClick={handleScrollToBottom}
92
+ size="icon"
93
+ type="button"
94
+ variant="outline"
95
+ {...props}
96
+ >
97
+ <ArrowDownIcon className="size-4" />
98
+ </Button>
99
+ )
100
+ );
101
+ };
102
+
103
+ const getMessageText = (message: UIMessage): string =>
104
+ message.parts
105
+ .filter((part) => part.type === "text")
106
+ .map((part) => part.text)
107
+ .join("");
108
+
109
+ export type ConversationDownloadProps = Omit<
110
+ ComponentProps<typeof Button>,
111
+ "onClick"
112
+ > & {
113
+ messages: UIMessage[];
114
+ filename?: string;
115
+ formatMessage?: (message: UIMessage, index: number) => string;
116
+ };
117
+
118
+ const defaultFormatMessage = (message: UIMessage): string => {
119
+ const roleLabel =
120
+ message.role.charAt(0).toUpperCase() + message.role.slice(1);
121
+ return `**${roleLabel}:** ${getMessageText(message)}`;
122
+ };
123
+
124
+ export const messagesToMarkdown = (
125
+ messages: UIMessage[],
126
+ formatMessage: (
127
+ message: UIMessage,
128
+ index: number
129
+ ) => string = defaultFormatMessage
130
+ ): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
131
+
132
+ export const ConversationDownload = ({
133
+ messages,
134
+ filename = "conversation.md",
135
+ formatMessage = defaultFormatMessage,
136
+ className,
137
+ children,
138
+ ...props
139
+ }: ConversationDownloadProps) => {
140
+ const handleDownload = useCallback(() => {
141
+ const markdown = messagesToMarkdown(messages, formatMessage);
142
+ const blob = new Blob([markdown], { type: "text/markdown" });
143
+ const url = URL.createObjectURL(blob);
144
+ const link = document.createElement("a");
145
+ link.href = url;
146
+ link.download = filename;
147
+ document.body.append(link);
148
+ link.click();
149
+ link.remove();
150
+ URL.revokeObjectURL(url);
151
+ }, [messages, filename, formatMessage]);
152
+
153
+ return (
154
+ <Button
155
+ className={cn(
156
+ "absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
157
+ className
158
+ )}
159
+ onClick={handleDownload}
160
+ size="icon"
161
+ type="button"
162
+ variant="outline"
163
+ {...props}
164
+ >
165
+ {children ?? <DownloadIcon className="size-4" />}
166
+ </Button>
167
+ );
168
+ };
@@ -0,0 +1,378 @@
1
+ "use client";
2
+
3
+ import { Button } from "@houston-ai/core";
4
+ import {
5
+ ButtonGroup,
6
+ ButtonGroupText,
7
+ } from "@houston-ai/core";
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipProvider,
12
+ TooltipTrigger,
13
+ } from "@houston-ai/core";
14
+ import { cn } from "@houston-ai/core";
15
+ import { cjk } from "@streamdown/cjk";
16
+ import { code } from "@streamdown/code";
17
+ import { math } from "@streamdown/math";
18
+ import { mermaid } from "@streamdown/mermaid";
19
+ import type { UIMessage } from "ai";
20
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
21
+ import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
22
+ import {
23
+ createContext,
24
+ memo,
25
+ useCallback,
26
+ useContext,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ } from "react";
31
+ import { Streamdown } from "streamdown";
32
+
33
+ const MessageAvatarContext = createContext<React.ReactNode | undefined>(undefined);
34
+
35
+ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
36
+ from: UIMessage["role"];
37
+ /** Optional badge avatar shown on the message bubble (e.g., channel logo). */
38
+ avatar?: React.ReactNode;
39
+ };
40
+
41
+ export const Message = ({ className, from, avatar, children, ...props }: MessageProps) => (
42
+ <MessageAvatarContext.Provider value={avatar}>
43
+ <div
44
+ className={cn(
45
+ "group flex w-full flex-col gap-2",
46
+ from === "user" ? "is-user ml-auto max-w-[70%] justify-end" : "is-assistant",
47
+ className
48
+ )}
49
+ {...props}
50
+ >
51
+ {children}
52
+ </div>
53
+ </MessageAvatarContext.Provider>
54
+ );
55
+
56
+ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
57
+
58
+ export const MessageContent = ({
59
+ children,
60
+ className,
61
+ ...props
62
+ }: MessageContentProps) => {
63
+ const avatar = useContext(MessageAvatarContext);
64
+ return (
65
+ <div className={cn("relative", avatar && "group-[.is-user]:mr-4")}>
66
+ <div
67
+ className={cn(
68
+ "flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-base leading-6",
69
+ "group-[.is-user]:ml-auto group-[.is-user]:rounded-[22px] group-[.is-user]:bg-muted group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-foreground",
70
+ "group-[.is-assistant]:text-foreground",
71
+ className
72
+ )}
73
+ {...props}
74
+ >
75
+ {children}
76
+ </div>
77
+ {avatar && (
78
+ <div className="absolute -bottom-1 -right-3.5 group-[.is-assistant]:-left-3.5 group-[.is-assistant]:right-auto">
79
+ {avatar}
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+ };
85
+
86
+ export type MessageActionsProps = ComponentProps<"div">;
87
+
88
+ export const MessageActions = ({
89
+ className,
90
+ children,
91
+ ...props
92
+ }: MessageActionsProps) => (
93
+ <div className={cn("flex items-center gap-1", className)} {...props}>
94
+ {children}
95
+ </div>
96
+ );
97
+
98
+ export type MessageActionProps = ComponentProps<typeof Button> & {
99
+ tooltip?: string;
100
+ label?: string;
101
+ };
102
+
103
+ export const MessageAction = ({
104
+ tooltip,
105
+ children,
106
+ label,
107
+ variant = "ghost",
108
+ size = "icon-sm",
109
+ ...props
110
+ }: MessageActionProps) => {
111
+ const button = (
112
+ <Button size={size} type="button" variant={variant} {...props}>
113
+ {children}
114
+ <span className="sr-only">{label || tooltip}</span>
115
+ </Button>
116
+ );
117
+
118
+ if (tooltip) {
119
+ return (
120
+ <TooltipProvider>
121
+ <Tooltip>
122
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
123
+ <TooltipContent>
124
+ <p>{tooltip}</p>
125
+ </TooltipContent>
126
+ </Tooltip>
127
+ </TooltipProvider>
128
+ );
129
+ }
130
+
131
+ return button;
132
+ };
133
+
134
+ interface MessageBranchContextType {
135
+ currentBranch: number;
136
+ totalBranches: number;
137
+ goToPrevious: () => void;
138
+ goToNext: () => void;
139
+ branches: ReactElement[];
140
+ setBranches: (branches: ReactElement[]) => void;
141
+ }
142
+
143
+ const MessageBranchContext = createContext<MessageBranchContextType | null>(
144
+ null
145
+ );
146
+
147
+ const useMessageBranch = () => {
148
+ const context = useContext(MessageBranchContext);
149
+
150
+ if (!context) {
151
+ throw new Error(
152
+ "MessageBranch components must be used within MessageBranch"
153
+ );
154
+ }
155
+
156
+ return context;
157
+ };
158
+
159
+ export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
160
+ defaultBranch?: number;
161
+ onBranchChange?: (branchIndex: number) => void;
162
+ };
163
+
164
+ export const MessageBranch = ({
165
+ defaultBranch = 0,
166
+ onBranchChange,
167
+ className,
168
+ ...props
169
+ }: MessageBranchProps) => {
170
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
171
+ const [branches, setBranches] = useState<ReactElement[]>([]);
172
+
173
+ const handleBranchChange = useCallback(
174
+ (newBranch: number) => {
175
+ setCurrentBranch(newBranch);
176
+ onBranchChange?.(newBranch);
177
+ },
178
+ [onBranchChange]
179
+ );
180
+
181
+ const goToPrevious = useCallback(() => {
182
+ const newBranch =
183
+ currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
184
+ handleBranchChange(newBranch);
185
+ }, [currentBranch, branches.length, handleBranchChange]);
186
+
187
+ const goToNext = useCallback(() => {
188
+ const newBranch =
189
+ currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
190
+ handleBranchChange(newBranch);
191
+ }, [currentBranch, branches.length, handleBranchChange]);
192
+
193
+ const contextValue = useMemo<MessageBranchContextType>(
194
+ () => ({
195
+ branches,
196
+ currentBranch,
197
+ goToNext,
198
+ goToPrevious,
199
+ setBranches,
200
+ totalBranches: branches.length,
201
+ }),
202
+ [branches, currentBranch, goToNext, goToPrevious]
203
+ );
204
+
205
+ return (
206
+ <MessageBranchContext.Provider value={contextValue}>
207
+ <div
208
+ className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
209
+ {...props}
210
+ />
211
+ </MessageBranchContext.Provider>
212
+ );
213
+ };
214
+
215
+ export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
216
+
217
+ export const MessageBranchContent = ({
218
+ children,
219
+ ...props
220
+ }: MessageBranchContentProps) => {
221
+ const { currentBranch, setBranches, branches } = useMessageBranch();
222
+ const childrenArray = useMemo(
223
+ () => (Array.isArray(children) ? children : [children]),
224
+ [children]
225
+ );
226
+
227
+ // Use useEffect to update branches when they change
228
+ useEffect(() => {
229
+ if (branches.length !== childrenArray.length) {
230
+ setBranches(childrenArray);
231
+ }
232
+ }, [childrenArray, branches, setBranches]);
233
+
234
+ return childrenArray.map((branch, index) => (
235
+ <div
236
+ className={cn(
237
+ "grid gap-2 overflow-hidden [&>div]:pb-0",
238
+ index === currentBranch ? "block" : "hidden"
239
+ )}
240
+ key={branch.key}
241
+ {...props}
242
+ >
243
+ {branch}
244
+ </div>
245
+ ));
246
+ };
247
+
248
+ export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
249
+
250
+ export const MessageBranchSelector = ({
251
+ className,
252
+ ...props
253
+ }: MessageBranchSelectorProps) => {
254
+ const { totalBranches } = useMessageBranch();
255
+
256
+ // Don't render if there's only one branch
257
+ if (totalBranches <= 1) {
258
+ return null;
259
+ }
260
+
261
+ return (
262
+ <ButtonGroup
263
+ className={cn(
264
+ "[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
265
+ className
266
+ )}
267
+ orientation="horizontal"
268
+ {...props}
269
+ />
270
+ );
271
+ };
272
+
273
+ export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
274
+
275
+ export const MessageBranchPrevious = ({
276
+ children,
277
+ ...props
278
+ }: MessageBranchPreviousProps) => {
279
+ const { goToPrevious, totalBranches } = useMessageBranch();
280
+
281
+ return (
282
+ <Button
283
+ aria-label="Previous branch"
284
+ disabled={totalBranches <= 1}
285
+ onClick={goToPrevious}
286
+ size="icon-sm"
287
+ type="button"
288
+ variant="ghost"
289
+ {...props}
290
+ >
291
+ {children ?? <ChevronLeftIcon size={14} />}
292
+ </Button>
293
+ );
294
+ };
295
+
296
+ export type MessageBranchNextProps = ComponentProps<typeof Button>;
297
+
298
+ export const MessageBranchNext = ({
299
+ children,
300
+ ...props
301
+ }: MessageBranchNextProps) => {
302
+ const { goToNext, totalBranches } = useMessageBranch();
303
+
304
+ return (
305
+ <Button
306
+ aria-label="Next branch"
307
+ disabled={totalBranches <= 1}
308
+ onClick={goToNext}
309
+ size="icon-sm"
310
+ type="button"
311
+ variant="ghost"
312
+ {...props}
313
+ >
314
+ {children ?? <ChevronRightIcon size={14} />}
315
+ </Button>
316
+ );
317
+ };
318
+
319
+ export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
320
+
321
+ export const MessageBranchPage = ({
322
+ className,
323
+ ...props
324
+ }: MessageBranchPageProps) => {
325
+ const { currentBranch, totalBranches } = useMessageBranch();
326
+
327
+ return (
328
+ <ButtonGroupText
329
+ className={cn(
330
+ "border-none bg-transparent text-muted-foreground shadow-none",
331
+ className
332
+ )}
333
+ {...props}
334
+ >
335
+ {currentBranch + 1} of {totalBranches}
336
+ </ButtonGroupText>
337
+ );
338
+ };
339
+
340
+ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
341
+
342
+ const streamdownPlugins = { cjk, code, math, mermaid };
343
+
344
+ export const MessageResponse = memo(
345
+ ({ className, ...props }: MessageResponseProps) => (
346
+ <Streamdown
347
+ className={cn(
348
+ "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
349
+ className
350
+ )}
351
+ plugins={streamdownPlugins}
352
+ {...props}
353
+ />
354
+ ),
355
+ (prevProps, nextProps) =>
356
+ prevProps.children === nextProps.children &&
357
+ nextProps.isAnimating === prevProps.isAnimating
358
+ );
359
+
360
+ MessageResponse.displayName = "MessageResponse";
361
+
362
+ export type MessageToolbarProps = ComponentProps<"div">;
363
+
364
+ export const MessageToolbar = ({
365
+ className,
366
+ children,
367
+ ...props
368
+ }: MessageToolbarProps) => (
369
+ <div
370
+ className={cn(
371
+ "mt-4 flex w-full items-center justify-between gap-4",
372
+ className
373
+ )}
374
+ {...props}
375
+ >
376
+ {children}
377
+ </div>
378
+ );