@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 +47 -0
- package/package.json +34 -0
- package/src/ai-elements/conversation.tsx +168 -0
- package/src/ai-elements/message.tsx +378 -0
- package/src/ai-elements/prompt-input.tsx +1507 -0
- package/src/ai-elements/reasoning.tsx +226 -0
- package/src/ai-elements/shimmer.tsx +77 -0
- package/src/ai-elements/suggestion.tsx +57 -0
- package/src/channel-avatar.tsx +57 -0
- package/src/chat-helpers.tsx +239 -0
- package/src/chat-input-parts.tsx +98 -0
- package/src/chat-input.tsx +231 -0
- package/src/chat-panel.tsx +194 -0
- package/src/feed-merge.ts +43 -0
- package/src/feed-to-messages.ts +174 -0
- package/src/index.ts +180 -0
- package/src/progress-panel.tsx +82 -0
- package/src/styles.css +3 -0
- package/src/types.ts +29 -0
- package/src/typewriter.tsx +43 -0
- package/src/use-progress-steps.ts +52 -0
|
@@ -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
|
+
}
|