@extrachill/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.
Files changed (83) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +154 -0
  3. package/css/chat.css +552 -0
  4. package/dist/Chat.d.ts +73 -0
  5. package/dist/Chat.d.ts.map +1 -0
  6. package/dist/Chat.js +50 -0
  7. package/dist/api.d.ts +68 -0
  8. package/dist/api.d.ts.map +1 -0
  9. package/dist/api.js +93 -0
  10. package/dist/components/AvailabilityGate.d.ts +19 -0
  11. package/dist/components/AvailabilityGate.d.ts.map +1 -0
  12. package/dist/components/AvailabilityGate.js +32 -0
  13. package/dist/components/ChatInput.d.ts +21 -0
  14. package/dist/components/ChatInput.d.ts.map +1 -0
  15. package/dist/components/ChatInput.js +52 -0
  16. package/dist/components/ChatMessage.d.ts +23 -0
  17. package/dist/components/ChatMessage.d.ts.map +1 -0
  18. package/dist/components/ChatMessage.js +34 -0
  19. package/dist/components/ChatMessages.d.ts +28 -0
  20. package/dist/components/ChatMessages.d.ts.map +1 -0
  21. package/dist/components/ChatMessages.js +121 -0
  22. package/dist/components/ErrorBoundary.d.ts +27 -0
  23. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  24. package/dist/components/ErrorBoundary.js +34 -0
  25. package/dist/components/SessionSwitcher.d.ts +25 -0
  26. package/dist/components/SessionSwitcher.d.ts.map +1 -0
  27. package/dist/components/SessionSwitcher.js +44 -0
  28. package/dist/components/ToolMessage.d.ts +34 -0
  29. package/dist/components/ToolMessage.d.ts.map +1 -0
  30. package/dist/components/ToolMessage.js +39 -0
  31. package/dist/components/TypingIndicator.d.ts +16 -0
  32. package/dist/components/TypingIndicator.d.ts.map +1 -0
  33. package/dist/components/TypingIndicator.js +14 -0
  34. package/dist/components/index.d.ts +9 -0
  35. package/dist/components/index.d.ts.map +1 -0
  36. package/dist/components/index.js +8 -0
  37. package/dist/hooks/index.d.ts +2 -0
  38. package/dist/hooks/index.d.ts.map +1 -0
  39. package/dist/hooks/index.js +1 -0
  40. package/dist/hooks/useChat.d.ts +102 -0
  41. package/dist/hooks/useChat.d.ts.map +1 -0
  42. package/dist/hooks/useChat.js +192 -0
  43. package/dist/index.d.ts +15 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +16 -0
  46. package/dist/normalizer.d.ts +24 -0
  47. package/dist/normalizer.d.ts.map +1 -0
  48. package/dist/normalizer.js +96 -0
  49. package/dist/types/adapter.d.ts +151 -0
  50. package/dist/types/adapter.d.ts.map +1 -0
  51. package/dist/types/adapter.js +11 -0
  52. package/dist/types/api.d.ts +137 -0
  53. package/dist/types/api.d.ts.map +1 -0
  54. package/dist/types/api.js +8 -0
  55. package/dist/types/index.d.ts +4 -0
  56. package/dist/types/index.d.ts.map +1 -0
  57. package/dist/types/index.js +1 -0
  58. package/dist/types/message.d.ts +62 -0
  59. package/dist/types/message.d.ts.map +1 -0
  60. package/dist/types/message.js +7 -0
  61. package/dist/types/session.d.ts +59 -0
  62. package/dist/types/session.d.ts.map +1 -0
  63. package/dist/types/session.js +7 -0
  64. package/package.json +61 -0
  65. package/src/Chat.tsx +157 -0
  66. package/src/api.ts +173 -0
  67. package/src/components/AvailabilityGate.tsx +85 -0
  68. package/src/components/ChatInput.tsx +114 -0
  69. package/src/components/ChatMessage.tsx +85 -0
  70. package/src/components/ChatMessages.tsx +193 -0
  71. package/src/components/ErrorBoundary.tsx +66 -0
  72. package/src/components/SessionSwitcher.tsx +129 -0
  73. package/src/components/ToolMessage.tsx +112 -0
  74. package/src/components/TypingIndicator.tsx +36 -0
  75. package/src/components/index.ts +8 -0
  76. package/src/hooks/index.ts +1 -0
  77. package/src/hooks/useChat.ts +310 -0
  78. package/src/index.ts +79 -0
  79. package/src/normalizer.ts +112 -0
  80. package/src/types/api.ts +146 -0
  81. package/src/types/index.ts +26 -0
  82. package/src/types/message.ts +66 -0
  83. package/src/types/session.ts +50 -0
@@ -0,0 +1,193 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react';
2
+ import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
3
+ import { ChatMessage } from './ChatMessage.tsx';
4
+ import { ToolMessage, type ToolGroup } from './ToolMessage.tsx';
5
+
6
+ export interface ChatMessagesProps {
7
+ /** All messages in the conversation. */
8
+ messages: ChatMessageType[];
9
+ /** How to render message content. Defaults to 'markdown'. */
10
+ contentFormat?: ContentFormat;
11
+ /** Custom content renderer passed through to ChatMessage. */
12
+ renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
13
+ /** Whether to show tool call/result messages. Defaults to false. */
14
+ showTools?: boolean;
15
+ /** Custom tool name display map. Maps tool function names to friendly labels. */
16
+ toolNames?: Record<string, string>;
17
+ /** Whether to auto-scroll to bottom on new messages. Defaults to true. */
18
+ autoScroll?: boolean;
19
+ /** Placeholder content shown when there are no messages. */
20
+ emptyState?: ReactNode;
21
+ /** Additional CSS class name. */
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * Scrollable message list with auto-scroll behavior.
27
+ *
28
+ * Filters system messages, groups tool_call/tool_result pairs,
29
+ * and renders user/assistant messages as ChatMessage components.
30
+ */
31
+ export function ChatMessages({
32
+ messages,
33
+ contentFormat,
34
+ renderContent,
35
+ showTools = false,
36
+ toolNames,
37
+ autoScroll = true,
38
+ emptyState,
39
+ className,
40
+ }: ChatMessagesProps) {
41
+ const bottomRef = useRef<HTMLDivElement>(null);
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+
44
+ useEffect(() => {
45
+ if (autoScroll && bottomRef.current) {
46
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
47
+ }
48
+ }, [messages, autoScroll]);
49
+
50
+ const displayItems = buildDisplayItems(messages, showTools);
51
+ const baseClass = 'ec-chat-messages';
52
+ const classes = [baseClass, className].filter(Boolean).join(' ');
53
+
54
+ if (displayItems.length === 0 && emptyState) {
55
+ return (
56
+ <div className={classes} ref={containerRef}>
57
+ <div className={`${baseClass}__empty`}>
58
+ {emptyState}
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <div className={classes} ref={containerRef}>
66
+ {displayItems.map((item) => {
67
+ if (item.type === 'message') {
68
+ return (
69
+ <ChatMessage
70
+ key={item.message.id}
71
+ message={item.message}
72
+ contentFormat={contentFormat}
73
+ renderContent={renderContent}
74
+ />
75
+ );
76
+ }
77
+
78
+ if (item.type === 'tool-group' && showTools) {
79
+ return (
80
+ <ToolMessage
81
+ key={item.group.callMessage.id}
82
+ group={item.group}
83
+ toolNames={toolNames}
84
+ />
85
+ );
86
+ }
87
+
88
+ return null;
89
+ })}
90
+ <div ref={bottomRef} />
91
+ </div>
92
+ );
93
+ }
94
+
95
+ type DisplayItem =
96
+ | { type: 'message'; message: ChatMessageType }
97
+ | { type: 'tool-group'; group: ToolGroup };
98
+
99
+ /**
100
+ * Build display items from raw messages.
101
+ *
102
+ * - Filters out system messages
103
+ * - Groups tool_call + tool_result pairs
104
+ * - Returns ordered display items
105
+ */
106
+ function buildDisplayItems(messages: ChatMessageType[], showTools: boolean): DisplayItem[] {
107
+ const items: DisplayItem[] = [];
108
+ const toolResultMap = new Map<string, ChatMessageType>();
109
+
110
+ // Pre-index tool results by tool name for pairing
111
+ if (showTools) {
112
+ for (const msg of messages) {
113
+ if (msg.role === 'tool_result' && msg.toolResult?.toolName) {
114
+ toolResultMap.set(msg.toolResult.toolName, msg);
115
+ }
116
+ }
117
+ }
118
+
119
+ const processedToolResults = new Set<string>();
120
+
121
+ for (const msg of messages) {
122
+ // Skip system messages
123
+ if (msg.role === 'system') continue;
124
+
125
+ // Skip tool messages when tools are hidden
126
+ if (!showTools && (msg.role === 'tool_call' || msg.role === 'tool_result')) continue;
127
+
128
+ // Handle user/assistant messages (assistant messages with toolCalls get both treatments)
129
+ if (msg.role === 'user' || msg.role === 'assistant') {
130
+ // If assistant message has tool calls, render the text part as a message
131
+ // and the tool calls as tool groups
132
+ if (msg.role === 'assistant' && msg.toolCalls?.length && showTools) {
133
+ // Only render text bubble if there's actual text content
134
+ if (msg.content.trim()) {
135
+ items.push({ type: 'message', message: msg });
136
+ }
137
+
138
+ for (const call of msg.toolCalls) {
139
+ const resultMsg = toolResultMap.get(call.name);
140
+ if (resultMsg) {
141
+ processedToolResults.add(resultMsg.id);
142
+ }
143
+
144
+ items.push({
145
+ type: 'tool-group',
146
+ group: {
147
+ callMessage: {
148
+ ...msg,
149
+ content: '',
150
+ toolCalls: [call],
151
+ },
152
+ resultMessage: resultMsg ?? null,
153
+ toolName: call.name,
154
+ parameters: call.parameters,
155
+ success: resultMsg?.toolResult?.success ?? null,
156
+ },
157
+ });
158
+ }
159
+ } else {
160
+ items.push({ type: 'message', message: msg });
161
+ }
162
+ continue;
163
+ }
164
+
165
+ // Handle standalone tool_call messages
166
+ if (msg.role === 'tool_call' && showTools) {
167
+ const toolName = msg.toolCalls?.[0]?.name ?? 'unknown';
168
+ const resultMsg = toolResultMap.get(toolName);
169
+ if (resultMsg) {
170
+ processedToolResults.add(resultMsg.id);
171
+ }
172
+
173
+ items.push({
174
+ type: 'tool-group',
175
+ group: {
176
+ callMessage: msg,
177
+ resultMessage: resultMsg ?? null,
178
+ toolName,
179
+ parameters: msg.toolCalls?.[0]?.parameters ?? {},
180
+ success: resultMsg?.toolResult?.success ?? null,
181
+ },
182
+ });
183
+ continue;
184
+ }
185
+
186
+ // Handle orphaned tool_result messages
187
+ if (msg.role === 'tool_result' && showTools && !processedToolResults.has(msg.id)) {
188
+ items.push({ type: 'message', message: msg });
189
+ }
190
+ }
191
+
192
+ return items;
193
+ }
@@ -0,0 +1,66 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+
3
+ export interface ErrorBoundaryProps {
4
+ /** Content to render when an error occurs. */
5
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
6
+ /** Called when an error is caught. */
7
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
8
+ /** Children to render. */
9
+ children: ReactNode;
10
+ }
11
+
12
+ interface ErrorBoundaryState {
13
+ error: Error | null;
14
+ }
15
+
16
+ /**
17
+ * Error boundary for the chat UI.
18
+ *
19
+ * Catches React render errors and shows a fallback UI with retry.
20
+ * Resets automatically when children change via key prop.
21
+ */
22
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
23
+ override state: ErrorBoundaryState = { error: null };
24
+
25
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
26
+ return { error };
27
+ }
28
+
29
+ override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
30
+ this.props.onError?.(error, errorInfo);
31
+ }
32
+
33
+ reset = () => {
34
+ this.setState({ error: null });
35
+ };
36
+
37
+ override render() {
38
+ const { error } = this.state;
39
+ const { fallback, children } = this.props;
40
+
41
+ if (error) {
42
+ if (typeof fallback === 'function') {
43
+ return fallback(error, this.reset);
44
+ }
45
+
46
+ if (fallback) {
47
+ return fallback;
48
+ }
49
+
50
+ return (
51
+ <div className="ec-chat-error">
52
+ <p className="ec-chat-error__message">Something went wrong in the chat.</p>
53
+ <button
54
+ className="ec-chat-error__retry"
55
+ onClick={this.reset}
56
+ type="button"
57
+ >
58
+ Try again
59
+ </button>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ return children;
65
+ }
66
+ }
@@ -0,0 +1,129 @@
1
+ import type { ChatSession } from '../types/index.ts';
2
+
3
+ export interface SessionSwitcherProps {
4
+ /** List of available sessions. */
5
+ sessions: ChatSession[];
6
+ /** Currently active session ID. */
7
+ activeSessionId?: string;
8
+ /** Called when a session is selected. */
9
+ onSelect: (sessionId: string) => void;
10
+ /** Called when the user wants to create a new session. */
11
+ onNew?: () => void;
12
+ /** Called when the user wants to delete a session. */
13
+ onDelete?: (sessionId: string) => void;
14
+ /** Whether sessions are currently loading. */
15
+ loading?: boolean;
16
+ /** Additional CSS class name. */
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Session switcher dropdown.
22
+ *
23
+ * Renders a list of sessions with the active one highlighted.
24
+ * Only rendered when the adapter declares `capabilities.sessions = true`.
25
+ */
26
+ export function SessionSwitcher({
27
+ sessions,
28
+ activeSessionId,
29
+ onSelect,
30
+ onNew,
31
+ onDelete,
32
+ loading = false,
33
+ className,
34
+ }: SessionSwitcherProps) {
35
+ const baseClass = 'ec-chat-sessions';
36
+ const classes = [baseClass, className].filter(Boolean).join(' ');
37
+
38
+ return (
39
+ <div className={classes}>
40
+ <div className={`${baseClass}__header`}>
41
+ <span className={`${baseClass}__title`}>Conversations</span>
42
+ {onNew && (
43
+ <button
44
+ className={`${baseClass}__new`}
45
+ onClick={onNew}
46
+ aria-label="New conversation"
47
+ type="button"
48
+ >
49
+ +
50
+ </button>
51
+ )}
52
+ </div>
53
+
54
+ {loading && (
55
+ <div className={`${baseClass}__loading`}>Loading...</div>
56
+ )}
57
+
58
+ <ul className={`${baseClass}__list`} role="listbox" aria-label="Chat sessions">
59
+ {sessions.map((session) => {
60
+ const isActive = session.id === activeSessionId;
61
+ const itemClass = [
62
+ `${baseClass}__item`,
63
+ isActive ? `${baseClass}__item--active` : '',
64
+ ].filter(Boolean).join(' ');
65
+
66
+ return (
67
+ <li
68
+ key={session.id}
69
+ className={itemClass}
70
+ role="option"
71
+ aria-selected={isActive}
72
+ >
73
+ <button
74
+ className={`${baseClass}__item-button`}
75
+ onClick={() => onSelect(session.id)}
76
+ type="button"
77
+ >
78
+ <span className={`${baseClass}__item-title`}>
79
+ {session.title ?? `Session ${session.id.slice(0, 8)}`}
80
+ </span>
81
+ <time
82
+ className={`${baseClass}__item-date`}
83
+ dateTime={session.updatedAt}
84
+ >
85
+ {formatRelativeDate(session.updatedAt)}
86
+ </time>
87
+ </button>
88
+ {onDelete && (
89
+ <button
90
+ className={`${baseClass}__item-delete`}
91
+ onClick={(e) => {
92
+ e.stopPropagation();
93
+ onDelete(session.id);
94
+ }}
95
+ aria-label={`Delete session ${session.title ?? session.id}`}
96
+ type="button"
97
+ >
98
+ \u00D7
99
+ </button>
100
+ )}
101
+ </li>
102
+ );
103
+ })}
104
+ </ul>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function formatRelativeDate(iso: string): string {
110
+ try {
111
+ const date = new Date(iso);
112
+ const now = new Date();
113
+ const diffMs = now.getTime() - date.getTime();
114
+ const diffMins = Math.floor(diffMs / 60_000);
115
+
116
+ if (diffMins < 1) return 'just now';
117
+ if (diffMins < 60) return `${diffMins}m ago`;
118
+
119
+ const diffHours = Math.floor(diffMins / 60);
120
+ if (diffHours < 24) return `${diffHours}h ago`;
121
+
122
+ const diffDays = Math.floor(diffHours / 24);
123
+ if (diffDays < 7) return `${diffDays}d ago`;
124
+
125
+ return date.toLocaleDateString();
126
+ } catch {
127
+ return '';
128
+ }
129
+ }
@@ -0,0 +1,112 @@
1
+ import { useState } from 'react';
2
+ import type { ChatMessage } from '../types/index.ts';
3
+
4
+ /**
5
+ * A paired tool call + result for display.
6
+ */
7
+ export interface ToolGroup {
8
+ /** The message containing the tool call. */
9
+ callMessage: ChatMessage;
10
+ /** The result message (null if still pending). */
11
+ resultMessage: ChatMessage | null;
12
+ /** Tool function name. */
13
+ toolName: string;
14
+ /** Parameters passed to the tool. */
15
+ parameters: Record<string, unknown>;
16
+ /** Whether the tool succeeded (null if pending). */
17
+ success: boolean | null;
18
+ }
19
+
20
+ export interface ToolMessageProps {
21
+ /** The tool call/result group to display. */
22
+ group: ToolGroup;
23
+ /** Map of tool names to friendly display labels. */
24
+ toolNames?: Record<string, string>;
25
+ /** Whether the tool details are initially expanded. Defaults to false. */
26
+ defaultExpanded?: boolean;
27
+ /** Additional CSS class name. */
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * Renders a collapsible tool call/result pair.
33
+ *
34
+ * Shows a summary line with the tool name (or friendly label),
35
+ * success/error indicator, and expandable details section.
36
+ */
37
+ export function ToolMessage({
38
+ group,
39
+ toolNames,
40
+ defaultExpanded = false,
41
+ className,
42
+ }: ToolMessageProps) {
43
+ const [expanded, setExpanded] = useState(defaultExpanded);
44
+
45
+ const displayName = toolNames?.[group.toolName] ?? formatToolName(group.toolName);
46
+ const baseClass = 'ec-chat-tool';
47
+ const statusClass = group.success === true
48
+ ? `${baseClass}--success`
49
+ : group.success === false
50
+ ? `${baseClass}--error`
51
+ : `${baseClass}--pending`;
52
+ const classes = [baseClass, statusClass, className].filter(Boolean).join(' ');
53
+
54
+ return (
55
+ <div className={classes}>
56
+ <button
57
+ className={`${baseClass}__header`}
58
+ onClick={() => setExpanded(!expanded)}
59
+ aria-expanded={expanded}
60
+ type="button"
61
+ >
62
+ <span className={`${baseClass}__icon`}>
63
+ {group.success === true ? '\u2713' : group.success === false ? '\u2717' : '\u2026'}
64
+ </span>
65
+ <span className={`${baseClass}__name`}>{displayName}</span>
66
+ <span className={`${baseClass}__chevron`}>{expanded ? '\u25B2' : '\u25BC'}</span>
67
+ </button>
68
+
69
+ {expanded && (
70
+ <div className={`${baseClass}__details`}>
71
+ {Object.keys(group.parameters).length > 0 && (
72
+ <div className={`${baseClass}__section`}>
73
+ <div className={`${baseClass}__section-label`}>Parameters</div>
74
+ <pre className={`${baseClass}__json`}>
75
+ {JSON.stringify(group.parameters, null, 2)}
76
+ </pre>
77
+ </div>
78
+ )}
79
+
80
+ {group.resultMessage && (
81
+ <div className={`${baseClass}__section`}>
82
+ <div className={`${baseClass}__section-label`}>Result</div>
83
+ <pre className={`${baseClass}__json`}>
84
+ {formatResult(group.resultMessage.content)}
85
+ </pre>
86
+ </div>
87
+ )}
88
+ </div>
89
+ )}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Convert snake_case tool name to a readable label.
96
+ */
97
+ function formatToolName(name: string): string {
98
+ return name
99
+ .replace(/_/g, ' ')
100
+ .replace(/\b\w/g, (c) => c.toUpperCase());
101
+ }
102
+
103
+ /**
104
+ * Try to pretty-print JSON content, fall back to raw string.
105
+ */
106
+ function formatResult(content: string): string {
107
+ try {
108
+ return JSON.stringify(JSON.parse(content), null, 2);
109
+ } catch {
110
+ return content;
111
+ }
112
+ }
@@ -0,0 +1,36 @@
1
+ export interface TypingIndicatorProps {
2
+ /** Whether the indicator is visible. */
3
+ visible: boolean;
4
+ /** Optional label text. Defaults to none (dots only). */
5
+ label?: string;
6
+ /** Additional CSS class name. */
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * Animated typing indicator with three bouncing dots.
12
+ *
13
+ * Renders as an assistant-style message bubble.
14
+ * The animation is pure CSS — no JS timers.
15
+ */
16
+ export function TypingIndicator({
17
+ visible,
18
+ label,
19
+ className,
20
+ }: TypingIndicatorProps) {
21
+ if (!visible) return null;
22
+
23
+ const baseClass = 'ec-chat-typing';
24
+ const classes = [baseClass, className].filter(Boolean).join(' ');
25
+
26
+ return (
27
+ <div className={classes} role="status" aria-label="Assistant is typing">
28
+ <div className={`${baseClass}__dots`}>
29
+ <span className={`${baseClass}__dot`} />
30
+ <span className={`${baseClass}__dot`} />
31
+ <span className={`${baseClass}__dot`} />
32
+ </div>
33
+ {label && <span className={`${baseClass}__label`}>{label}</span>}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,8 @@
1
+ export { ChatMessage, type ChatMessageProps } from './ChatMessage.tsx';
2
+ export { ChatMessages, type ChatMessagesProps } from './ChatMessages.tsx';
3
+ export { ChatInput, type ChatInputProps } from './ChatInput.tsx';
4
+ export { ToolMessage, type ToolMessageProps, type ToolGroup } from './ToolMessage.tsx';
5
+ export { TypingIndicator, type TypingIndicatorProps } from './TypingIndicator.tsx';
6
+ export { SessionSwitcher, type SessionSwitcherProps } from './SessionSwitcher.tsx';
7
+ export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary.tsx';
8
+ export { AvailabilityGate, type AvailabilityGateProps } from './AvailabilityGate.tsx';
@@ -0,0 +1 @@
1
+ export { useChat, type UseChatOptions, type UseChatReturn } from './useChat.ts';