@houston-ai/chat 0.6.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.
@@ -0,0 +1,226 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ } from "@houston-ai/core";
9
+ import { cn } from "@houston-ai/core";
10
+ import { cjk } from "@streamdown/cjk";
11
+ import { code } from "@streamdown/code";
12
+ import { math } from "@streamdown/math";
13
+ import { mermaid } from "@streamdown/mermaid";
14
+ import { BrainIcon, ChevronDownIcon } from "lucide-react";
15
+ import type { ComponentProps, ReactNode } from "react";
16
+ import {
17
+ createContext,
18
+ memo,
19
+ useCallback,
20
+ useContext,
21
+ useEffect,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ } from "react";
26
+ import { Streamdown } from "streamdown";
27
+
28
+ import { Shimmer } from "./shimmer";
29
+
30
+ interface ReasoningContextValue {
31
+ isStreaming: boolean;
32
+ isOpen: boolean;
33
+ setIsOpen: (open: boolean) => void;
34
+ duration: number | undefined;
35
+ }
36
+
37
+ const ReasoningContext = createContext<ReasoningContextValue | null>(null);
38
+
39
+ export const useReasoning = () => {
40
+ const context = useContext(ReasoningContext);
41
+ if (!context) {
42
+ throw new Error("Reasoning components must be used within Reasoning");
43
+ }
44
+ return context;
45
+ };
46
+
47
+ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
48
+ isStreaming?: boolean;
49
+ open?: boolean;
50
+ defaultOpen?: boolean;
51
+ onOpenChange?: (open: boolean) => void;
52
+ duration?: number;
53
+ };
54
+
55
+ const AUTO_CLOSE_DELAY = 1000;
56
+ const MS_IN_S = 1000;
57
+
58
+ export const Reasoning = memo(
59
+ ({
60
+ className,
61
+ isStreaming = false,
62
+ open,
63
+ defaultOpen,
64
+ onOpenChange,
65
+ duration: durationProp,
66
+ children,
67
+ ...props
68
+ }: ReasoningProps) => {
69
+ const resolvedDefaultOpen = defaultOpen ?? isStreaming;
70
+ // Track if defaultOpen was explicitly set to false (to prevent auto-open)
71
+ const isExplicitlyClosed = defaultOpen === false;
72
+
73
+ const [isOpen, setIsOpen] = useControllableState<boolean>({
74
+ defaultProp: resolvedDefaultOpen,
75
+ onChange: onOpenChange,
76
+ prop: open,
77
+ });
78
+ const [duration, setDuration] = useControllableState<number | undefined>({
79
+ defaultProp: undefined,
80
+ prop: durationProp,
81
+ });
82
+
83
+ const hasEverStreamedRef = useRef(isStreaming);
84
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
85
+ const startTimeRef = useRef<number | null>(null);
86
+
87
+ // Track when streaming starts and compute duration
88
+ useEffect(() => {
89
+ if (isStreaming) {
90
+ hasEverStreamedRef.current = true;
91
+ if (startTimeRef.current === null) {
92
+ startTimeRef.current = Date.now();
93
+ }
94
+ } else if (startTimeRef.current !== null) {
95
+ setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
96
+ startTimeRef.current = null;
97
+ }
98
+ }, [isStreaming, setDuration]);
99
+
100
+ // Auto-open when streaming starts (unless explicitly closed)
101
+ useEffect(() => {
102
+ if (isStreaming && !isOpen && !isExplicitlyClosed) {
103
+ setIsOpen(true);
104
+ }
105
+ }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
106
+
107
+ // Auto-close when streaming ends (once only, and only if it ever streamed)
108
+ useEffect(() => {
109
+ if (
110
+ hasEverStreamedRef.current &&
111
+ !isStreaming &&
112
+ isOpen &&
113
+ !hasAutoClosed
114
+ ) {
115
+ const timer = setTimeout(() => {
116
+ setIsOpen(false);
117
+ setHasAutoClosed(true);
118
+ }, AUTO_CLOSE_DELAY);
119
+
120
+ return () => clearTimeout(timer);
121
+ }
122
+ }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
123
+
124
+ const handleOpenChange = useCallback(
125
+ (newOpen: boolean) => {
126
+ setIsOpen(newOpen);
127
+ },
128
+ [setIsOpen]
129
+ );
130
+
131
+ const contextValue = useMemo(
132
+ () => ({ duration, isOpen, isStreaming, setIsOpen }),
133
+ [duration, isOpen, isStreaming, setIsOpen]
134
+ );
135
+
136
+ return (
137
+ <ReasoningContext.Provider value={contextValue}>
138
+ <Collapsible
139
+ className={cn("not-prose mb-4", className)}
140
+ onOpenChange={handleOpenChange}
141
+ open={isOpen}
142
+ {...props}
143
+ >
144
+ {children}
145
+ </Collapsible>
146
+ </ReasoningContext.Provider>
147
+ );
148
+ }
149
+ );
150
+
151
+ export type ReasoningTriggerProps = ComponentProps<
152
+ typeof CollapsibleTrigger
153
+ > & {
154
+ getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
155
+ };
156
+
157
+ const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
158
+ if (isStreaming || duration === 0) {
159
+ return <Shimmer duration={1}>Thinking...</Shimmer>;
160
+ }
161
+ if (duration === undefined) {
162
+ return <p>Thought for a few seconds</p>;
163
+ }
164
+ return <p>Thought for {duration} seconds</p>;
165
+ };
166
+
167
+ export const ReasoningTrigger = memo(
168
+ ({
169
+ className,
170
+ children,
171
+ getThinkingMessage = defaultGetThinkingMessage,
172
+ ...props
173
+ }: ReasoningTriggerProps) => {
174
+ const { isStreaming, isOpen, duration } = useReasoning();
175
+
176
+ return (
177
+ <CollapsibleTrigger
178
+ className={cn(
179
+ "flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
180
+ className
181
+ )}
182
+ {...props}
183
+ >
184
+ {children ?? (
185
+ <>
186
+ <BrainIcon className="size-4" />
187
+ {getThinkingMessage(isStreaming, duration)}
188
+ <ChevronDownIcon
189
+ className={cn(
190
+ "size-4 transition-transform",
191
+ isOpen ? "rotate-180" : "rotate-0"
192
+ )}
193
+ />
194
+ </>
195
+ )}
196
+ </CollapsibleTrigger>
197
+ );
198
+ }
199
+ );
200
+
201
+ export type ReasoningContentProps = ComponentProps<
202
+ typeof CollapsibleContent
203
+ > & {
204
+ children: string;
205
+ };
206
+
207
+ const streamdownPlugins = { cjk, code, math, mermaid };
208
+
209
+ export const ReasoningContent = memo(
210
+ ({ className, children, ...props }: ReasoningContentProps) => (
211
+ <CollapsibleContent
212
+ className={cn(
213
+ "mt-4 text-sm",
214
+ "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
215
+ className
216
+ )}
217
+ {...props}
218
+ >
219
+ <Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
220
+ </CollapsibleContent>
221
+ )
222
+ );
223
+
224
+ Reasoning.displayName = "Reasoning";
225
+ ReasoningTrigger.displayName = "ReasoningTrigger";
226
+ ReasoningContent.displayName = "ReasoningContent";
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { cn } from "@houston-ai/core";
4
+ import type { MotionProps } from "motion/react";
5
+ import { motion } from "motion/react";
6
+ import type { CSSProperties, ElementType, JSX } from "react";
7
+ import { memo, useMemo } from "react";
8
+
9
+ type MotionHTMLProps = MotionProps & Record<string, unknown>;
10
+
11
+ // Cache motion components at module level to avoid creating during render
12
+ const motionComponentCache = new Map<
13
+ keyof JSX.IntrinsicElements,
14
+ React.ComponentType<MotionHTMLProps>
15
+ >();
16
+
17
+ const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
18
+ let component = motionComponentCache.get(element);
19
+ if (!component) {
20
+ component = motion.create(element);
21
+ motionComponentCache.set(element, component);
22
+ }
23
+ return component;
24
+ };
25
+
26
+ export interface TextShimmerProps {
27
+ children: string;
28
+ as?: ElementType;
29
+ className?: string;
30
+ duration?: number;
31
+ spread?: number;
32
+ }
33
+
34
+ const ShimmerComponent = ({
35
+ children,
36
+ as: Component = "p",
37
+ className,
38
+ duration = 2,
39
+ spread = 2,
40
+ }: TextShimmerProps) => {
41
+ const MotionComponent = getMotionComponent(
42
+ Component as keyof JSX.IntrinsicElements
43
+ );
44
+
45
+ const dynamicSpread = useMemo(
46
+ () => (children?.length ?? 0) * spread,
47
+ [children, spread]
48
+ );
49
+
50
+ return (
51
+ <MotionComponent
52
+ animate={{ backgroundPosition: "0% center" }}
53
+ className={cn(
54
+ "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
55
+ "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
56
+ className
57
+ )}
58
+ initial={{ backgroundPosition: "100% center" }}
59
+ style={
60
+ {
61
+ "--spread": `${dynamicSpread}px`,
62
+ backgroundImage:
63
+ "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
64
+ } as CSSProperties
65
+ }
66
+ transition={{
67
+ duration,
68
+ ease: "linear",
69
+ repeat: Number.POSITIVE_INFINITY,
70
+ }}
71
+ >
72
+ {children}
73
+ </MotionComponent>
74
+ );
75
+ };
76
+
77
+ export const Shimmer = memo(ShimmerComponent);
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Button } from "@houston-ai/core";
4
+ import {
5
+ ScrollArea,
6
+ ScrollBar,
7
+ } from "@houston-ai/core";
8
+ import { cn } from "@houston-ai/core";
9
+ import type { ComponentProps } from "react";
10
+ import { useCallback } from "react";
11
+
12
+ export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
13
+
14
+ export const Suggestions = ({
15
+ className,
16
+ children,
17
+ ...props
18
+ }: SuggestionsProps) => (
19
+ <ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
20
+ <div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
21
+ {children}
22
+ </div>
23
+ <ScrollBar className="hidden" orientation="horizontal" />
24
+ </ScrollArea>
25
+ );
26
+
27
+ export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
28
+ suggestion: string;
29
+ onClick?: (suggestion: string) => void;
30
+ };
31
+
32
+ export const Suggestion = ({
33
+ suggestion,
34
+ onClick,
35
+ className,
36
+ variant = "outline",
37
+ size = "sm",
38
+ children,
39
+ ...props
40
+ }: SuggestionProps) => {
41
+ const handleClick = useCallback(() => {
42
+ onClick?.(suggestion);
43
+ }, [onClick, suggestion]);
44
+
45
+ return (
46
+ <Button
47
+ className={cn("cursor-pointer rounded-full px-4", className)}
48
+ onClick={handleClick}
49
+ size={size}
50
+ type="button"
51
+ variant={variant}
52
+ {...props}
53
+ >
54
+ {children || suggestion}
55
+ </Button>
56
+ );
57
+ };
@@ -0,0 +1,57 @@
1
+ import { cn } from "@houston-ai/core";
2
+
3
+ export type ChannelSource = "telegram" | "slack" | "desktop" | string;
4
+
5
+ interface ChannelAvatarProps {
6
+ source: ChannelSource;
7
+ size?: "sm" | "md";
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Small branded avatar for messaging channel sources.
13
+ * Shows the platform logo in a circular badge.
14
+ */
15
+ export function ChannelAvatar({ source, size = "sm", className }: ChannelAvatarProps) {
16
+ const sizeClass = size === "sm" ? "size-6" : "size-8";
17
+ const iconSize = size === "sm" ? 14 : 18;
18
+
19
+ return (
20
+ <div
21
+ className={cn(
22
+ "rounded-full flex items-center justify-center shrink-0",
23
+ source === "telegram" && "bg-[#2AABEE]",
24
+ source === "slack" && "bg-[#4A154B]",
25
+ !["telegram", "slack"].includes(source) && "bg-muted",
26
+ sizeClass,
27
+ className,
28
+ )}
29
+ title={source.charAt(0).toUpperCase() + source.slice(1)}
30
+ >
31
+ {source === "telegram" && <TelegramIcon size={iconSize} />}
32
+ {source === "slack" && <SlackIcon size={iconSize} />}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ function TelegramIcon({ size }: { size: number }) {
38
+ return (
39
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
40
+ <path
41
+ d="M20.665 3.717l-17.73 6.837c-1.21.486-1.203 1.161-.222 1.462l4.552 1.42 10.532-6.645c.498-.303.953-.14.579.192l-8.533 7.701h-.002l.002.001-.314 4.692c.46 0 .663-.211.921-.46l2.211-2.15 4.599 3.397c.848.467 1.457.227 1.668-.785l3.019-14.228c.309-1.239-.473-1.8-1.282-1.434z"
42
+ fill="white"
43
+ />
44
+ </svg>
45
+ );
46
+ }
47
+
48
+ function SlackIcon({ size }: { size: number }) {
49
+ return (
50
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
51
+ <path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
52
+ <path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
53
+ <path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.163 0a2.528 2.528 0 0 1 2.523 2.522v6.312z" fill="#2EB67D"/>
54
+ <path d="M15.163 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.163 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1-2.52-2.523 2.527 2.527 0 0 1 2.52-2.52h6.315A2.528 2.528 0 0 1 24 15.163a2.528 2.528 0 0 1-2.522 2.523h-6.315z" fill="#ECB22E"/>
55
+ </svg>
56
+ );
57
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Chat helper components -- tool activity display.
3
+ *
4
+ * Generic version: no Houston-specific tool detection. Consumers can provide
5
+ * custom tool mappings or a renderToolResult render prop to handle
6
+ * application-specific tool results.
7
+ */
8
+
9
+ import { useEffect, useRef, useState } from "react";
10
+ import type { ReactNode } from "react";
11
+ import type { ToolEntry } from "./feed-to-messages";
12
+ import { Loader2, CheckIcon } from "lucide-react";
13
+
14
+ // Re-export types and conversion for convenient imports
15
+ export type { ToolEntry, ChatMessage } from "./feed-to-messages";
16
+ export { feedItemsToMessages } from "./feed-to-messages";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // ToolActivity -- shows tool usage with a live, alive feel
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const DEFAULT_TOOL_LABELS: Record<string, string> = {
23
+ Read: "Reading file",
24
+ Write: "Writing file",
25
+ Edit: "Editing file",
26
+ Bash: "Running command",
27
+ Glob: "Searching files",
28
+ Grep: "Searching code",
29
+ WebSearch: "Searching the web",
30
+ WebFetch: "Fetching page",
31
+ ToolSearch: "Looking up tools",
32
+ };
33
+
34
+ function humanizeToolName(
35
+ name: string,
36
+ customLabels?: Record<string, string>,
37
+ ): string {
38
+ const short = name.includes("__") ? name.split("__").pop()! : name;
39
+ const labels = { ...DEFAULT_TOOL_LABELS, ...customLabels };
40
+ return labels[short] || short.replace(/_/g, " ");
41
+ }
42
+
43
+ interface CollapsedTool {
44
+ name: string;
45
+ count: number;
46
+ hasResult: boolean;
47
+ /** The input of the last tool in the group (shown when active) */
48
+ lastInput?: Record<string, unknown>;
49
+ }
50
+
51
+ function collapseToolEntries(tools: ToolEntry[]): CollapsedTool[] {
52
+ const result: CollapsedTool[] = [];
53
+ for (const tool of tools) {
54
+ const last = result[result.length - 1];
55
+ if (last && last.name === tool.name) {
56
+ last.count++;
57
+ if (tool.result) last.hasResult = true;
58
+ last.lastInput = tool.input as Record<string, unknown> | undefined;
59
+ } else {
60
+ result.push({
61
+ name: tool.name,
62
+ count: 1,
63
+ hasResult: !!tool.result,
64
+ lastInput: tool.input as Record<string, unknown> | undefined,
65
+ });
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Split collapsed entries into completed groups + the active tool.
73
+ * When the last entry in a group is still running (no result, streaming),
74
+ * peel it off as a separate "active" item so the user sees:
75
+ * [check] Running command 23x
76
+ * [spinner] Running command — npm install
77
+ */
78
+ function splitActive(
79
+ collapsed: CollapsedTool[],
80
+ isStreaming: boolean,
81
+ ): { completed: CollapsedTool[]; active: CollapsedTool | null } {
82
+ if (!isStreaming || collapsed.length === 0) {
83
+ return { completed: collapsed, active: null };
84
+ }
85
+ const last = collapsed[collapsed.length - 1];
86
+ if (last.hasResult) {
87
+ return { completed: collapsed, active: null };
88
+ }
89
+ // Peel the active tool off the last group
90
+ if (last.count > 1) {
91
+ const completedLast = { ...last, count: last.count - 1, hasResult: true };
92
+ const activeTool = { ...last, count: 1, hasResult: false };
93
+ return {
94
+ completed: [...collapsed.slice(0, -1), completedLast],
95
+ active: activeTool,
96
+ };
97
+ }
98
+ return {
99
+ completed: collapsed.slice(0, -1),
100
+ active: last,
101
+ };
102
+ }
103
+
104
+ export interface ToolActivityProps {
105
+ tools: ToolEntry[];
106
+ isStreaming: boolean;
107
+ /** Custom tool name → human label mappings, merged with defaults */
108
+ toolLabels?: Record<string, string>;
109
+ }
110
+
111
+ /** Extract a short detail string from tool input for display. */
112
+ function toolDetail(input?: Record<string, unknown>): string | null {
113
+ if (!input) return null;
114
+ // Bash: show command
115
+ if (typeof input.command === "string") {
116
+ const cmd = input.command as string;
117
+ return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
118
+ }
119
+ // Read/Write/Edit: show file path
120
+ if (typeof input.file_path === "string") {
121
+ const fp = input.file_path as string;
122
+ const parts = fp.split("/");
123
+ return parts.length > 2 ? parts.slice(-2).join("/") : fp;
124
+ }
125
+ // Grep/Glob: show pattern
126
+ if (typeof input.pattern === "string") return input.pattern as string;
127
+ return null;
128
+ }
129
+
130
+ export function ToolActivity({
131
+ tools,
132
+ isStreaming,
133
+ toolLabels,
134
+ }: ToolActivityProps) {
135
+ const [elapsed, setElapsed] = useState(0);
136
+ const startRef = useRef(Date.now());
137
+
138
+ useEffect(() => {
139
+ if (!isStreaming) return;
140
+ startRef.current = Date.now();
141
+ const interval = setInterval(() => {
142
+ setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
143
+ }, 1000);
144
+ return () => clearInterval(interval);
145
+ }, [isStreaming]);
146
+
147
+ const collapsed = collapseToolEntries(tools);
148
+ const { completed, active } = splitActive(collapsed, isStreaming);
149
+
150
+ return (
151
+ <div className="space-y-0.5 py-1">
152
+ {completed.map((item, i) => (
153
+ <div
154
+ key={i}
155
+ className="flex items-center gap-2 text-xs py-0.5 text-muted-foreground/40"
156
+ >
157
+ <CheckIcon className="size-3 text-muted-foreground/30 shrink-0" />
158
+ <span>{humanizeToolName(item.name, toolLabels)}</span>
159
+ {item.count > 1 && (
160
+ <span className="text-muted-foreground/30">{item.count}x</span>
161
+ )}
162
+ </div>
163
+ ))}
164
+ {active && (
165
+ <div className="flex items-center gap-2 text-xs py-0.5 text-foreground/70">
166
+ <Loader2 className="size-3 animate-spin text-foreground/40 shrink-0" />
167
+ <span className="font-medium">
168
+ {humanizeToolName(active.name, toolLabels)}
169
+ </span>
170
+ {toolDetail(active.lastInput) && (
171
+ <span className="text-muted-foreground/40 truncate max-w-[300px]">
172
+ {toolDetail(active.lastInput)}
173
+ </span>
174
+ )}
175
+ </div>
176
+ )}
177
+ {isStreaming && tools.length > 0 && (
178
+ <div className="text-[10px] text-muted-foreground/30 pl-5 pt-0.5">
179
+ {tools.length} {tools.length === 1 ? "step" : "steps"} · {elapsed}s
180
+ </div>
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // ToolsAndCards -- generic version with optional custom tool result rendering
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export interface ToolsAndCardsProps {
191
+ tools: ToolEntry[];
192
+ isStreaming: boolean;
193
+ /** Custom tool name → human label mappings for ToolActivity */
194
+ toolLabels?: Record<string, string>;
195
+ /**
196
+ * Predicate to identify "special" tools that should be rendered via
197
+ * renderToolResult instead of the default ToolActivity list.
198
+ * Defaults to returning false (all tools shown in ToolActivity).
199
+ */
200
+ isSpecialTool?: (toolName: string) => boolean;
201
+ /**
202
+ * Render prop for special tool results. Called for each tool where
203
+ * isSpecialTool returns true and the tool has a result.
204
+ */
205
+ renderToolResult?: (tool: ToolEntry, index: number) => ReactNode;
206
+ }
207
+
208
+ export function ToolsAndCards({
209
+ tools,
210
+ isStreaming,
211
+ toolLabels,
212
+ isSpecialTool,
213
+ renderToolResult,
214
+ }: ToolsAndCardsProps) {
215
+ const specialTools = isSpecialTool
216
+ ? tools.filter((t) => isSpecialTool(t.name) && t.result)
217
+ : [];
218
+ const otherTools = isSpecialTool
219
+ ? tools.filter((t) => !isSpecialTool(t.name))
220
+ : tools;
221
+
222
+ return (
223
+ <>
224
+ {otherTools.length > 0 && (
225
+ <ToolActivity
226
+ tools={otherTools}
227
+ isStreaming={isStreaming}
228
+ toolLabels={toolLabels}
229
+ />
230
+ )}
231
+ {renderToolResult &&
232
+ specialTools.map((t, i) => (
233
+ <div key={`special-${i}`} className="py-3">
234
+ {renderToolResult(t, i)}
235
+ </div>
236
+ ))}
237
+ </>
238
+ );
239
+ }