@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,174 @@
1
+ /**
2
+ * Convert FeedItem[] to ChatMessage[] for rendering.
3
+ *
4
+ * Groups consecutive feed items into logical messages, same as how
5
+ * AI Elements structures its message list. Pairs tool_call items with
6
+ * their corresponding tool_result items.
7
+ */
8
+
9
+ import type { FeedItem } from "./types";
10
+
11
+ export interface ToolEntry {
12
+ name: string;
13
+ input?: unknown;
14
+ result?: { content: string; is_error: boolean };
15
+ }
16
+
17
+ export interface ChatMessage {
18
+ key: string;
19
+ from: "user" | "assistant";
20
+ content: string;
21
+ isStreaming: boolean;
22
+ reasoning?: { content: string; isStreaming: boolean };
23
+ tools: ToolEntry[];
24
+ /** Source channel if the message came from an external channel. */
25
+ source?: string;
26
+ }
27
+
28
+ export function feedItemsToMessages(items: FeedItem[]): ChatMessage[] {
29
+ const messages: ChatMessage[] = [];
30
+ let cur: ChatMessage | null = null;
31
+
32
+ function getCur(): ChatMessage | null {
33
+ return cur;
34
+ }
35
+
36
+ const flush = () => {
37
+ if (cur) {
38
+ messages.push(cur);
39
+ cur = null;
40
+ }
41
+ };
42
+
43
+ const ensureAssistant = (): ChatMessage => {
44
+ if (!cur || cur.from !== "assistant") {
45
+ flush();
46
+ cur = {
47
+ key: `assistant-${messages.length}`,
48
+ from: "assistant",
49
+ content: "",
50
+ isStreaming: false,
51
+ tools: [],
52
+ };
53
+ }
54
+ return cur;
55
+ };
56
+
57
+ for (const item of items) {
58
+ switch (item.feed_type) {
59
+ case "user_message": {
60
+ flush();
61
+ const { source, text } = extractSource(item.data);
62
+ messages.push({
63
+ key: `user-${messages.length}`,
64
+ from: "user",
65
+ content: text,
66
+ isStreaming: false,
67
+ tools: [],
68
+ source,
69
+ });
70
+ break;
71
+ }
72
+
73
+ case "assistant_text": {
74
+ const msg = ensureAssistant();
75
+ msg.content = item.data;
76
+ msg.isStreaming = false;
77
+ flush();
78
+ break;
79
+ }
80
+
81
+ case "assistant_text_streaming": {
82
+ const msg = ensureAssistant();
83
+ msg.content = item.data;
84
+ msg.isStreaming = true;
85
+ break;
86
+ }
87
+
88
+ case "thinking_streaming":
89
+ case "thinking": {
90
+ const isStream = item.feed_type === "thinking_streaming";
91
+ const prev = getCur();
92
+ if (
93
+ prev &&
94
+ prev.from === "assistant" &&
95
+ (prev.tools.length > 0 || prev.content)
96
+ ) {
97
+ flush();
98
+ }
99
+ const msg = ensureAssistant();
100
+ msg.reasoning = { content: item.data, isStreaming: isStream };
101
+ if (isStream) msg.isStreaming = true;
102
+ if (!isStream) flush();
103
+ break;
104
+ }
105
+
106
+ case "tool_call": {
107
+ const msg = ensureAssistant();
108
+ msg.tools.push({ name: item.data.name, input: item.data.input });
109
+ if (!msg.content) msg.isStreaming = true;
110
+ break;
111
+ }
112
+
113
+ case "tool_result": {
114
+ // Find the most recent unmatched tool_call — it might be in the
115
+ // current message OR in an already-flushed one (thinking blocks
116
+ // can cause flushes between tool_call and tool_result).
117
+ let matched = false;
118
+ const active = getCur();
119
+ if (active && active.from === "assistant") {
120
+ for (let j = active.tools.length - 1; j >= 0; j--) {
121
+ if (!active.tools[j].result) {
122
+ active.tools[j].result = {
123
+ content: item.data.content,
124
+ is_error: item.data.is_error,
125
+ };
126
+ matched = true;
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ if (!matched) {
132
+ // Search flushed messages backwards
133
+ for (let m = messages.length - 1; m >= 0 && !matched; m--) {
134
+ const msg = messages[m];
135
+ if (msg.from !== "assistant") continue;
136
+ for (let j = msg.tools.length - 1; j >= 0; j--) {
137
+ if (!msg.tools[j].result) {
138
+ msg.tools[j].result = {
139
+ content: item.data.content,
140
+ is_error: item.data.is_error,
141
+ };
142
+ matched = true;
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ break;
149
+ }
150
+
151
+ case "system_message":
152
+ break;
153
+
154
+ case "final_result":
155
+ flush();
156
+ break;
157
+ }
158
+ }
159
+
160
+ flush();
161
+ return messages;
162
+ }
163
+
164
+ /** Extract a `[ChannelName]` prefix from a user message, if present. */
165
+ function extractSource(text: string): { source?: string; text: string } {
166
+ const match = text.match(/^\[(\w+)\]\s*/);
167
+ if (match) {
168
+ return {
169
+ source: match[1].toLowerCase(),
170
+ text: text.slice(match[0].length),
171
+ };
172
+ }
173
+ return { text };
174
+ }
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ // === Types ===
2
+ export type { FeedItem, RunStatus } from "./types";
3
+ export type { ToolEntry, ChatMessage } from "./feed-to-messages";
4
+
5
+ // === AI Elements: Conversation ===
6
+ export {
7
+ Conversation,
8
+ ConversationContent,
9
+ ConversationEmptyState,
10
+ ConversationScrollButton,
11
+ ConversationDownload,
12
+ messagesToMarkdown,
13
+ } from "./ai-elements/conversation";
14
+ export type {
15
+ ConversationProps,
16
+ ConversationContentProps,
17
+ ConversationEmptyStateProps,
18
+ ConversationScrollButtonProps,
19
+ ConversationDownloadProps,
20
+ } from "./ai-elements/conversation";
21
+
22
+ // === AI Elements: Message ===
23
+ export {
24
+ Message,
25
+ MessageContent,
26
+ MessageActions,
27
+ MessageAction,
28
+ MessageBranch,
29
+ MessageBranchContent,
30
+ MessageBranchSelector,
31
+ MessageBranchPrevious,
32
+ MessageBranchNext,
33
+ MessageBranchPage,
34
+ MessageResponse,
35
+ MessageToolbar,
36
+ } from "./ai-elements/message";
37
+ export type {
38
+ MessageProps,
39
+ MessageContentProps,
40
+ MessageActionsProps,
41
+ MessageActionProps,
42
+ MessageBranchProps,
43
+ MessageBranchContentProps,
44
+ MessageBranchSelectorProps,
45
+ MessageBranchPreviousProps,
46
+ MessageBranchNextProps,
47
+ MessageBranchPageProps,
48
+ MessageResponseProps,
49
+ MessageToolbarProps,
50
+ } from "./ai-elements/message";
51
+
52
+ // === AI Elements: Reasoning ===
53
+ export {
54
+ Reasoning,
55
+ ReasoningTrigger,
56
+ ReasoningContent,
57
+ useReasoning,
58
+ } from "./ai-elements/reasoning";
59
+ export type {
60
+ ReasoningProps,
61
+ ReasoningTriggerProps,
62
+ ReasoningContentProps,
63
+ } from "./ai-elements/reasoning";
64
+
65
+ // === AI Elements: Prompt Input ===
66
+ export {
67
+ PromptInput,
68
+ PromptInputProvider,
69
+ PromptInputBody,
70
+ PromptInputTextarea,
71
+ PromptInputHeader,
72
+ PromptInputFooter,
73
+ PromptInputTools,
74
+ PromptInputButton,
75
+ PromptInputSubmit,
76
+ PromptInputActionMenu,
77
+ PromptInputActionMenuTrigger,
78
+ PromptInputActionMenuContent,
79
+ PromptInputActionMenuItem,
80
+ PromptInputActionAddAttachments,
81
+ PromptInputActionAddScreenshot,
82
+ PromptInputSelect,
83
+ PromptInputSelectTrigger,
84
+ PromptInputSelectContent,
85
+ PromptInputSelectItem,
86
+ PromptInputSelectValue,
87
+ PromptInputHoverCard,
88
+ PromptInputHoverCardTrigger,
89
+ PromptInputHoverCardContent,
90
+ PromptInputTabsList,
91
+ PromptInputTab,
92
+ PromptInputTabLabel,
93
+ PromptInputTabBody,
94
+ PromptInputTabItem,
95
+ PromptInputCommand,
96
+ PromptInputCommandInput,
97
+ PromptInputCommandList,
98
+ PromptInputCommandEmpty,
99
+ PromptInputCommandGroup,
100
+ PromptInputCommandItem,
101
+ PromptInputCommandSeparator,
102
+ usePromptInputController,
103
+ useProviderAttachments,
104
+ usePromptInputAttachments,
105
+ usePromptInputReferencedSources,
106
+ } from "./ai-elements/prompt-input";
107
+ export type {
108
+ PromptInputProps,
109
+ PromptInputProviderProps,
110
+ PromptInputMessage,
111
+ PromptInputBodyProps,
112
+ PromptInputTextareaProps,
113
+ PromptInputHeaderProps,
114
+ PromptInputFooterProps,
115
+ PromptInputToolsProps,
116
+ PromptInputButtonProps,
117
+ PromptInputButtonTooltip,
118
+ PromptInputSubmitProps,
119
+ PromptInputActionMenuProps,
120
+ PromptInputActionMenuTriggerProps,
121
+ PromptInputActionMenuContentProps,
122
+ PromptInputActionMenuItemProps,
123
+ PromptInputActionAddAttachmentsProps,
124
+ PromptInputActionAddScreenshotProps,
125
+ PromptInputSelectProps,
126
+ PromptInputSelectTriggerProps,
127
+ PromptInputSelectContentProps,
128
+ PromptInputSelectItemProps,
129
+ PromptInputSelectValueProps,
130
+ PromptInputHoverCardProps,
131
+ PromptInputHoverCardTriggerProps,
132
+ PromptInputHoverCardContentProps,
133
+ PromptInputTabsListProps,
134
+ PromptInputTabProps,
135
+ PromptInputTabLabelProps,
136
+ PromptInputTabBodyProps,
137
+ PromptInputTabItemProps,
138
+ PromptInputCommandProps,
139
+ PromptInputCommandInputProps,
140
+ PromptInputCommandListProps,
141
+ PromptInputCommandEmptyProps,
142
+ PromptInputCommandGroupProps,
143
+ PromptInputCommandItemProps,
144
+ PromptInputCommandSeparatorProps,
145
+ PromptInputControllerProps,
146
+ AttachmentsContext,
147
+ TextInputContext,
148
+ ReferencedSourcesContext,
149
+ } from "./ai-elements/prompt-input";
150
+
151
+ // === AI Elements: Shimmer ===
152
+ export { Shimmer } from "./ai-elements/shimmer";
153
+ export type { TextShimmerProps } from "./ai-elements/shimmer";
154
+
155
+ // === AI Elements: Suggestion ===
156
+ export { Suggestions, Suggestion } from "./ai-elements/suggestion";
157
+ export type { SuggestionsProps, SuggestionProps } from "./ai-elements/suggestion";
158
+
159
+ // === Chat Components ===
160
+ export { ChatPanel } from "./chat-panel";
161
+ export type { ChatPanelProps } from "./chat-panel";
162
+
163
+ export { ChatInput } from "./chat-input";
164
+ export type { ChatInputProps } from "./chat-input";
165
+ export type { AttachMenuItem } from "./chat-input-parts";
166
+
167
+ export { ToolActivity, ToolsAndCards, feedItemsToMessages } from "./chat-helpers";
168
+ export type { ToolActivityProps, ToolsAndCardsProps } from "./chat-helpers";
169
+
170
+ // === Progress ===
171
+ export { useProgressSteps } from "./use-progress-steps";
172
+ export type { ProgressStep, StepStatus } from "./use-progress-steps";
173
+ export { ProgressPanel } from "./progress-panel";
174
+ export type { ProgressPanelProps } from "./progress-panel";
175
+
176
+ // === Utilities ===
177
+ export { Typewriter } from "./typewriter";
178
+ export { mergeFeedItem } from "./feed-merge";
179
+ export { ChannelAvatar } from "./channel-avatar";
180
+ export type { ChannelSource } from "./channel-avatar";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ProgressPanel — shows what an agent is working on during a session.
3
+ *
4
+ * Renders as a right-side panel alongside chat when the agent has called
5
+ * update_progress. Steps are shown as an animated checklist:
6
+ * - pending: open circle
7
+ * - active: spinning loader (highlighted)
8
+ * - done: filled checkmark
9
+ */
10
+
11
+ import { ScrollArea, cn } from "@houston-ai/core";
12
+ import { Check, Circle, Loader2 } from "lucide-react";
13
+ import type { ProgressStep } from "./use-progress-steps";
14
+
15
+ export interface ProgressPanelProps {
16
+ steps: ProgressStep[];
17
+ /** Header title. Defaults to "Working on". */
18
+ title?: string;
19
+ }
20
+
21
+ export function ProgressPanel({ steps, title = "Working on" }: ProgressPanelProps) {
22
+ const doneCount = steps.filter((s) => s.status === "done").length;
23
+ const total = steps.length;
24
+
25
+ return (
26
+ <div className="flex flex-col h-full border-l border-border bg-secondary/30">
27
+ {/* Header */}
28
+ <div className="px-5 pt-5 pb-4 border-b border-border">
29
+ <h2 className="text-sm font-medium text-foreground">{title}</h2>
30
+ {total > 0 && (
31
+ <p className="text-xs text-muted-foreground mt-0.5">
32
+ {doneCount} of {total} steps complete
33
+ </p>
34
+ )}
35
+ </div>
36
+
37
+ {/* Step list */}
38
+ <ScrollArea className="flex-1">
39
+ <div className="px-5 py-4 space-y-1">
40
+ {steps.map((step, i) => (
41
+ <StepRow key={i} step={step} />
42
+ ))}
43
+ </div>
44
+ </ScrollArea>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function StepRow({ step }: { step: ProgressStep }) {
50
+ return (
51
+ <div
52
+ className={cn(
53
+ "flex items-start gap-3 px-3 py-2.5 rounded-lg transition-colors",
54
+ step.status === "active" && "bg-accent/50",
55
+ )}
56
+ >
57
+ <div className="mt-0.5 shrink-0">
58
+ {step.status === "done" && (
59
+ <div className="size-5 rounded-full bg-[#00a240] flex items-center justify-center">
60
+ <Check className="size-3 text-white" strokeWidth={3} />
61
+ </div>
62
+ )}
63
+ {step.status === "active" && (
64
+ <Loader2 className="size-5 text-foreground/60 animate-spin" strokeWidth={1.5} />
65
+ )}
66
+ {step.status === "pending" && (
67
+ <Circle className="size-5 text-muted-foreground/25" strokeWidth={1.5} />
68
+ )}
69
+ </div>
70
+ <p
71
+ className={cn(
72
+ "text-sm leading-snug",
73
+ step.status === "done" && "text-foreground/50",
74
+ step.status === "active" && "text-foreground font-medium",
75
+ step.status === "pending" && "text-foreground/60",
76
+ )}
77
+ >
78
+ {step.title}
79
+ </p>
80
+ </div>
81
+ );
82
+ }
package/src/styles.css ADDED
@@ -0,0 +1,3 @@
1
+ /* @houston-ai/chat — Tell Tailwind to scan this package's components */
2
+ @source "./ai-elements";
3
+ @source ".";
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Chat-related types extracted from Houston's type system.
2
+ // Only the types needed by chat components are included here.
3
+
4
+ export type FeedItem =
5
+ | { feed_type: "assistant_text"; data: string }
6
+ | { feed_type: "assistant_text_streaming"; data: string }
7
+ | { feed_type: "thinking"; data: string }
8
+ | { feed_type: "thinking_streaming"; data: string }
9
+ | { feed_type: "user_message"; data: string }
10
+ | { feed_type: "tool_call"; data: { name: string; input: unknown } }
11
+ | { feed_type: "tool_result"; data: { content: string; is_error: boolean } }
12
+ | { feed_type: "system_message"; data: string }
13
+ | {
14
+ feed_type: "final_result";
15
+ data: {
16
+ result: string;
17
+ cost_usd: number | null;
18
+ duration_ms: number | null;
19
+ };
20
+ };
21
+
22
+ export type RunStatus =
23
+ | "running"
24
+ | "completed"
25
+ | "failed"
26
+ | "approved"
27
+ | "needs_you"
28
+ | "done"
29
+ | "error";
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+
3
+ interface Props {
4
+ text: string;
5
+ speed?: number;
6
+ onComplete?: () => void;
7
+ children: (displayed: string) => React.ReactNode;
8
+ }
9
+
10
+ export function Typewriter({
11
+ text,
12
+ speed = 20,
13
+ onComplete,
14
+ children,
15
+ }: Props) {
16
+ const [displayed, setDisplayed] = useState("");
17
+ const indexRef = useRef(0);
18
+ const completedRef = useRef(false);
19
+
20
+ useEffect(() => {
21
+ indexRef.current = 0;
22
+ completedRef.current = false;
23
+ setDisplayed("");
24
+
25
+ const interval = setInterval(() => {
26
+ indexRef.current++;
27
+ if (indexRef.current >= text.length) {
28
+ setDisplayed(text);
29
+ clearInterval(interval);
30
+ if (!completedRef.current) {
31
+ completedRef.current = true;
32
+ onComplete?.();
33
+ }
34
+ } else {
35
+ setDisplayed(text.slice(0, indexRef.current));
36
+ }
37
+ }, speed);
38
+
39
+ return () => clearInterval(interval);
40
+ }, [text, speed, onComplete]);
41
+
42
+ return <>{children(displayed)}</>;
43
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Extracts the latest update_progress tool call from the feed.
3
+ *
4
+ * Agents call update_progress({ steps: [{ title, status }] }) to communicate
5
+ * high-level goals. Each call replaces the previous — the last call in the
6
+ * feed is the authoritative state.
7
+ */
8
+
9
+ import { useMemo } from "react";
10
+ import type { FeedItem } from "./types";
11
+
12
+ export type StepStatus = "pending" | "active" | "done";
13
+
14
+ export interface ProgressStep {
15
+ title: string;
16
+ status: StepStatus;
17
+ }
18
+
19
+ function isProgressInput(
20
+ input: unknown,
21
+ ): input is { steps: { title: string; status: string }[] } {
22
+ if (!input || typeof input !== "object") return false;
23
+ const obj = input as Record<string, unknown>;
24
+ return Array.isArray(obj.steps);
25
+ }
26
+
27
+ function parseStatus(raw: string): StepStatus {
28
+ if (raw === "done") return "done";
29
+ if (raw === "active") return "active";
30
+ return "pending";
31
+ }
32
+
33
+ export function useProgressSteps(feedItems: FeedItem[]): ProgressStep[] {
34
+ return useMemo(() => {
35
+ let latest: ProgressStep[] | null = null;
36
+
37
+ for (const item of feedItems) {
38
+ if (
39
+ item.feed_type === "tool_call" &&
40
+ item.data.name === "update_progress" &&
41
+ isProgressInput(item.data.input)
42
+ ) {
43
+ latest = item.data.input.steps.map((s) => ({
44
+ title: s.title,
45
+ status: parseStatus(s.status),
46
+ }));
47
+ }
48
+ }
49
+
50
+ return latest ?? [];
51
+ }, [feedItems]);
52
+ }