@steerable/agent-ui 0.2.0 → 0.2.4

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 (178) hide show
  1. package/dist/cards/ActionSegmentCard.d.ts +18 -0
  2. package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
  3. package/dist/cards/ActionSegmentCard.js +26 -0
  4. package/dist/cards/ActionSegmentCard.js.map +1 -0
  5. package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
  6. package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
  7. package/dist/cards/AnalysisDocumentCard.js +11 -0
  8. package/dist/cards/AnalysisDocumentCard.js.map +1 -0
  9. package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
  10. package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
  11. package/dist/cards/AskUserQuestionsCard.js +171 -0
  12. package/dist/cards/AskUserQuestionsCard.js.map +1 -0
  13. package/dist/cards/CoverageReportCard.d.ts +41 -0
  14. package/dist/cards/CoverageReportCard.d.ts.map +1 -0
  15. package/dist/cards/CoverageReportCard.js +55 -0
  16. package/dist/cards/CoverageReportCard.js.map +1 -0
  17. package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
  18. package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
  19. package/dist/cards/OrchestrationPlanCard.js +103 -0
  20. package/dist/cards/OrchestrationPlanCard.js.map +1 -0
  21. package/dist/cards/PlanSelectorCard.d.ts +40 -0
  22. package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
  23. package/dist/cards/PlanSelectorCard.js +106 -0
  24. package/dist/cards/PlanSelectorCard.js.map +1 -0
  25. package/dist/cards/PlanStepsCard.d.ts +18 -0
  26. package/dist/cards/PlanStepsCard.d.ts.map +1 -0
  27. package/dist/cards/PlanStepsCard.js +25 -0
  28. package/dist/cards/PlanStepsCard.js.map +1 -0
  29. package/dist/cards/QuizCard.d.ts +44 -0
  30. package/dist/cards/QuizCard.d.ts.map +1 -0
  31. package/dist/cards/QuizCard.js +113 -0
  32. package/dist/cards/QuizCard.js.map +1 -0
  33. package/dist/cards/ResearchPlanCard.d.ts +23 -0
  34. package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
  35. package/dist/cards/ResearchPlanCard.js +50 -0
  36. package/dist/cards/ResearchPlanCard.js.map +1 -0
  37. package/dist/cards/SearchSourcesCard.d.ts +29 -0
  38. package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
  39. package/dist/cards/SearchSourcesCard.js +83 -0
  40. package/dist/cards/SearchSourcesCard.js.map +1 -0
  41. package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
  42. package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
  43. package/dist/cards/SuggestedRepliesCard.js +14 -0
  44. package/dist/cards/SuggestedRepliesCard.js.map +1 -0
  45. package/dist/cards/SummaryMessageCard.d.ts +40 -0
  46. package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
  47. package/dist/cards/SummaryMessageCard.js +56 -0
  48. package/dist/cards/SummaryMessageCard.js.map +1 -0
  49. package/dist/cards/ThinkingProcessCard.d.ts +46 -0
  50. package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
  51. package/dist/cards/ThinkingProcessCard.js +79 -0
  52. package/dist/cards/ThinkingProcessCard.js.map +1 -0
  53. package/dist/cards/ToolExecutionCard.d.ts +21 -0
  54. package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
  55. package/dist/cards/ToolExecutionCard.js +65 -0
  56. package/dist/cards/ToolExecutionCard.js.map +1 -0
  57. package/dist/cards/icons.d.ts +38 -0
  58. package/dist/cards/icons.d.ts.map +1 -0
  59. package/dist/cards/icons.js +87 -0
  60. package/dist/cards/icons.js.map +1 -0
  61. package/dist/cards/index.d.ts +38 -0
  62. package/dist/cards/index.d.ts.map +1 -0
  63. package/dist/cards/index.js +24 -0
  64. package/dist/cards/index.js.map +1 -0
  65. package/dist/cards/types.d.ts +24 -0
  66. package/dist/cards/types.d.ts.map +1 -0
  67. package/dist/cards/types.js +7 -0
  68. package/dist/cards/types.js.map +1 -0
  69. package/dist/components/AgentSelector.d.ts +23 -0
  70. package/dist/components/AgentSelector.d.ts.map +1 -0
  71. package/dist/components/AgentSelector.js +63 -0
  72. package/dist/components/AgentSelector.js.map +1 -0
  73. package/dist/components/AgentSelector.stories.d.ts +12 -0
  74. package/dist/components/AgentSelector.stories.d.ts.map +1 -0
  75. package/dist/components/AgentSelector.stories.js +95 -0
  76. package/dist/components/AgentSelector.stories.js.map +1 -0
  77. package/dist/components/ChatPanel.d.ts +138 -21
  78. package/dist/components/ChatPanel.d.ts.map +1 -1
  79. package/dist/components/ChatPanel.js +181 -26
  80. package/dist/components/ChatPanel.js.map +1 -1
  81. package/dist/components/index.d.ts +3 -1
  82. package/dist/components/index.d.ts.map +1 -1
  83. package/dist/components/index.js +1 -0
  84. package/dist/components/index.js.map +1 -1
  85. package/dist/index.d.ts +2 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/state/ChatSessionProvider.d.ts +59 -0
  90. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  91. package/dist/state/ChatSessionProvider.js +71 -0
  92. package/dist/state/ChatSessionProvider.js.map +1 -0
  93. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  94. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  95. package/dist/state/MockChatStreamTransport.js +86 -0
  96. package/dist/state/MockChatStreamTransport.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  98. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  100. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  101. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  102. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  103. package/dist/state/bridgeLegacySSE.js +102 -0
  104. package/dist/state/bridgeLegacySSE.js.map +1 -0
  105. package/dist/state/index.d.ts +32 -0
  106. package/dist/state/index.d.ts.map +1 -0
  107. package/dist/state/index.js +24 -0
  108. package/dist/state/index.js.map +1 -0
  109. package/dist/state/parseSSE.d.ts +50 -0
  110. package/dist/state/parseSSE.d.ts.map +1 -0
  111. package/dist/state/parseSSE.js +112 -0
  112. package/dist/state/parseSSE.js.map +1 -0
  113. package/dist/state/useChatComposer.d.ts +51 -0
  114. package/dist/state/useChatComposer.d.ts.map +1 -0
  115. package/dist/state/useChatComposer.js +82 -0
  116. package/dist/state/useChatComposer.js.map +1 -0
  117. package/dist/state/useChatList.d.ts +71 -0
  118. package/dist/state/useChatList.d.ts.map +1 -0
  119. package/dist/state/useChatList.js +158 -0
  120. package/dist/state/useChatList.js.map +1 -0
  121. package/dist/state/useChatSession.d.ts +29 -0
  122. package/dist/state/useChatSession.d.ts.map +1 -0
  123. package/dist/state/useChatSession.js +37 -0
  124. package/dist/state/useChatSession.js.map +1 -0
  125. package/dist/state/useScrollLock.d.ts +35 -0
  126. package/dist/state/useScrollLock.d.ts.map +1 -0
  127. package/dist/state/useScrollLock.js +74 -0
  128. package/dist/state/useScrollLock.js.map +1 -0
  129. package/dist/state/useToolCallStream.d.ts +38 -0
  130. package/dist/state/useToolCallStream.d.ts.map +1 -0
  131. package/dist/state/useToolCallStream.js +74 -0
  132. package/dist/state/useToolCallStream.js.map +1 -0
  133. package/package.json +10 -2
  134. package/src/cards/ActionSegmentCard.tsx +60 -0
  135. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  136. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  137. package/src/cards/CoverageReportCard.tsx +215 -0
  138. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  139. package/src/cards/PlanSelectorCard.tsx +312 -0
  140. package/src/cards/PlanStepsCard.tsx +52 -0
  141. package/src/cards/QuizCard.tsx +309 -0
  142. package/src/cards/ResearchPlanCard.tsx +169 -0
  143. package/src/cards/SearchSourcesCard.tsx +244 -0
  144. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  145. package/src/cards/SummaryMessageCard.tsx +145 -0
  146. package/src/cards/ThinkingProcessCard.tsx +155 -0
  147. package/src/cards/ToolExecutionCard.tsx +152 -0
  148. package/src/cards/cards.test.tsx +492 -0
  149. package/src/cards/icons.tsx +249 -0
  150. package/src/cards/index.ts +53 -0
  151. package/src/cards/types.ts +27 -0
  152. package/src/components/AgentSelector.stories.tsx +113 -0
  153. package/src/components/AgentSelector.test.tsx +70 -0
  154. package/src/components/AgentSelector.tsx +147 -0
  155. package/src/components/ChatPanel.test.tsx +130 -0
  156. package/src/components/ChatPanel.tsx +612 -138
  157. package/src/components/index.ts +13 -1
  158. package/src/index.ts +43 -0
  159. package/src/state/ChatSessionProvider.test.tsx +85 -0
  160. package/src/state/ChatSessionProvider.tsx +98 -0
  161. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  162. package/src/state/MockChatStreamTransport.test.ts +163 -0
  163. package/src/state/MockChatStreamTransport.ts +123 -0
  164. package/src/state/bridgeLegacySSE.test.ts +134 -0
  165. package/src/state/bridgeLegacySSE.ts +134 -0
  166. package/src/state/index.ts +82 -0
  167. package/src/state/parseSSE.test.ts +123 -0
  168. package/src/state/parseSSE.ts +123 -0
  169. package/src/state/useChatComposer.test.ts +145 -0
  170. package/src/state/useChatComposer.ts +117 -0
  171. package/src/state/useChatList.test.ts +135 -0
  172. package/src/state/useChatList.ts +217 -0
  173. package/src/state/useChatSession.test.ts +61 -0
  174. package/src/state/useChatSession.ts +64 -0
  175. package/src/state/useScrollLock.test.ts +99 -0
  176. package/src/state/useScrollLock.ts +105 -0
  177. package/src/state/useToolCallStream.test.ts +86 -0
  178. package/src/state/useToolCallStream.ts +110 -0
@@ -1,188 +1,662 @@
1
1
  /**
2
- * `ChatPanel` — the foundational orchestration shell.
2
+ * `ChatPanel` — the orchestration shell, now exposed as a Radix-style
3
+ * compound: `ChatPanel.Root / .Header / .Messages / .Input / .Empty /
4
+ * .StreamingStatus`.
3
5
  *
4
- * Composes `MessageList` + a minimal input row + a stop button. The component
5
- * is intentionally minimal so consumers can layer on the production-grade
6
- * extras (model selector, tool toggles, mention picker, slash commands, file
7
- * upload, automation alerts, share-image button, multi-tab agent switcher,
8
- * orchestration sidebar) without having to fork the chat itself.
6
+ * Compound API
7
+ * ------------
8
+ * <ChatPanel.Root className="…">
9
+ * <ChatPanel.Header>{customHeader}</ChatPanel.Header>
10
+ * <ChatPanel.Messages
11
+ * messages={messages}
12
+ * isStreaming={isStreaming}
13
+ * renderMessage={renderRichBubble}
14
+ * emptyState={<ChatPanel.Empty onSelectPrompt={…} />}
15
+ * />
16
+ * <ChatPanel.Input
17
+ * value={draft}
18
+ * onChange={setDraft}
19
+ * onSubmit={send}
20
+ * onCancel={cancel}
21
+ * isStreaming={isStreaming}
22
+ * />
23
+ * </ChatPanel.Root>
9
24
  *
10
- * Production callers in `deeppath/apps/web` will build their fancier
11
- * `<ChatHeader />` and `<ChatInput />` separately and feed `ChatPanel` only
12
- * the `messages` + `onSubmit` props — the rest is rendered around it.
25
+ * Each sub-component owns one slot of the chat. The .Messages and .Input
26
+ * subs accept `as` / `slots` escape hatches so consumers can replace any one
27
+ * leaf without re-implementing the rest.
28
+ *
29
+ * Monolithic alias
30
+ * ----------------
31
+ * <ChatPanel messages={messages} onSubmit={onSubmit} … />
32
+ *
33
+ * The 0.2.x props remain valid — they internally render the compound. New
34
+ * consumers should reach for the compound; the alias exists to make the
35
+ * 0.2 -> 0.3 cutover non-breaking for in-flight integrations.
13
36
  */
14
37
 
15
- import { useCallback, useState } from 'react';
38
+ import {
39
+ forwardRef,
40
+ useCallback,
41
+ useEffect,
42
+ useImperativeHandle,
43
+ useRef,
44
+ useState,
45
+ type KeyboardEvent,
46
+ } from 'react';
16
47
  import type { ChatMessage, ToolCall, ToolResult } from '@steerable/agent-protocol';
17
48
  import { MessageList, type MessageRendererProps } from './MessageList.js';
18
49
  import { ToolCallRenderer } from './ToolCallRenderer.js';
19
50
  import { cn } from './cn.js';
51
+ import { useChatSessionContext } from '../state/ChatSessionProvider.js';
20
52
 
21
- export interface ChatPanelProps {
22
- messages: ChatMessage[];
23
- isStreaming?: boolean;
24
- /**
25
- * Called when the user submits the input. Receives the trimmed string.
26
- * The parent is responsible for calling `useChatStream().sendUserMessage`.
27
- */
28
- onSubmit: (input: { content: string }) => void | Promise<void>;
29
- onCancel?: () => void;
53
+ // ---------------------------------------------------------------------------
54
+ // Root
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export interface ChatPanelRootProps {
58
+ children: React.ReactNode;
30
59
  className?: string;
31
- inputPlaceholder?: string;
32
- /** Slot rendered above the message list (header, agent picker, …). */
33
- header?: React.ReactNode;
34
60
  /**
35
- * Tool-call renderer. Default uses the framework's `ToolCallRenderer`;
36
- * pass `() => null` to suppress, or your own component to inject a
37
- * domain-specific renderer (CIFLog cards, SQL query previews, …).
61
+ * Opt out of the default `bg-agent-canvas text-agent-foreground` styling
62
+ * handy when the consumer's host container already paints the
63
+ * background (e.g. deeppath's `bg-card` rounded shell) and you don't want
64
+ * two layers fighting over which wins in the cascade.
38
65
  */
66
+ unstyled?: boolean;
67
+ /** Render as a different host element (e.g. 'section', 'main'). */
68
+ as?: keyof React.JSX.IntrinsicElements;
69
+ }
70
+
71
+ function ChatPanelRoot({
72
+ children,
73
+ className,
74
+ unstyled = false,
75
+ as: As = 'div',
76
+ }: ChatPanelRootProps) {
77
+ // The `as any` keeps the JSX surface flexible without forcing every host
78
+ // element's prop variance through the inference machinery.
79
+ const Component = As as React.ElementType;
80
+ return (
81
+ <Component
82
+ className={cn(
83
+ 'flex h-full w-full flex-col',
84
+ unstyled ? null : 'bg-agent-canvas text-agent-foreground',
85
+ className,
86
+ )}
87
+ >
88
+ {children}
89
+ </Component>
90
+ );
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Header
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export interface ChatPanelHeaderProps {
98
+ children?: React.ReactNode;
99
+ className?: string;
100
+ }
101
+
102
+ function ChatPanelHeader({ children, className }: ChatPanelHeaderProps) {
103
+ if (!children) return null;
104
+ return (
105
+ <div
106
+ className={cn(
107
+ 'flex items-center gap-2 border-b border-agent-border bg-agent-canvas px-3 py-2',
108
+ className,
109
+ )}
110
+ >
111
+ {children}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Messages
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export interface ChatPanelMessagesProps {
121
+ messages: ChatMessage[];
122
+ isStreaming?: boolean;
123
+ renderMessage?: (props: MessageRendererProps) => React.ReactNode;
39
124
  renderToolCall?: (call: ToolCall, result?: ToolResult) => React.ReactNode;
40
- /** Empty-state slot for the message list. */
41
125
  emptyState?: React.ReactNode;
42
- /** Disable the input (e.g. during initial chat load). */
43
- disabled?: boolean;
126
+ className?: string;
44
127
  }
45
128
 
46
129
  function defaultRenderToolCall(call: ToolCall, result?: ToolResult) {
47
130
  return <ToolCallRenderer key={call.id} call={call} result={result} />;
48
131
  }
49
132
 
50
- export function ChatPanel(props: ChatPanelProps) {
133
+ function ChatPanelMessages(props: ChatPanelMessagesProps) {
51
134
  const {
52
135
  messages,
53
136
  isStreaming = false,
54
- onSubmit,
55
- onCancel,
56
- className,
57
- inputPlaceholder = 'Send a message…',
58
- header,
137
+ renderMessage,
59
138
  renderToolCall = defaultRenderToolCall,
60
139
  emptyState,
61
- disabled = false,
140
+ className,
62
141
  } = props;
63
- const [draft, setDraft] = useState('');
64
142
 
65
- const handleSubmit = useCallback(
66
- async (e?: React.FormEvent) => {
67
- e?.preventDefault();
68
- const trimmed = draft.trim();
69
- if (!trimmed || disabled) return;
70
- setDraft('');
71
- await onSubmit({ content: trimmed });
143
+ const renderBubble = useCallback(
144
+ (rendererProps: MessageRendererProps) => {
145
+ if (renderMessage) return renderMessage(rendererProps);
146
+ return (
147
+ <DefaultRichBubble
148
+ {...rendererProps}
149
+ renderToolCall={renderToolCall}
150
+ />
151
+ );
72
152
  },
73
- [draft, disabled, onSubmit],
153
+ [renderMessage, renderToolCall],
74
154
  );
75
155
 
76
- const renderMessage = useCallback(
77
- (rendererProps: MessageRendererProps) => {
78
- const { message, isLastAssistant, isStreaming: streaming } = rendererProps;
79
- const isAssistant = message.role === 'assistant';
80
- return (
156
+ return (
157
+ <div className={cn('flex-1 overflow-hidden', className)}>
158
+ <MessageList
159
+ messages={messages}
160
+ isStreaming={isStreaming}
161
+ renderMessage={renderBubble}
162
+ emptyState={emptyState}
163
+ />
164
+ </div>
165
+ );
166
+ }
167
+
168
+ function DefaultRichBubble({
169
+ message,
170
+ isLastAssistant,
171
+ isStreaming,
172
+ index,
173
+ renderToolCall,
174
+ }: MessageRendererProps & {
175
+ renderToolCall: (call: ToolCall, result?: ToolResult) => React.ReactNode;
176
+ }) {
177
+ const isAssistant = message.role === 'assistant';
178
+ return (
179
+ <div
180
+ key={message.id ?? index}
181
+ data-role={message.role}
182
+ className={cn('flex w-full', isAssistant ? 'justify-start' : 'justify-end')}
183
+ >
184
+ <div
185
+ className={cn(
186
+ 'flex max-w-[80%] flex-col gap-2',
187
+ isAssistant ? 'items-start' : 'items-end',
188
+ )}
189
+ >
81
190
  <div
82
- key={message.id ?? rendererProps.index}
83
- data-role={message.role}
84
191
  className={cn(
85
- 'flex w-full',
86
- isAssistant ? 'justify-start' : 'justify-end',
192
+ 'rounded-agent-md border px-3 py-2 text-sm leading-relaxed',
193
+ isAssistant
194
+ ? 'border-agent-border bg-agent-muted text-agent-foreground'
195
+ : 'border-transparent bg-agent-accent text-agent-accent-foreground',
87
196
  )}
88
197
  >
89
- <div
90
- className={cn(
91
- 'flex max-w-[80%] flex-col gap-2',
92
- isAssistant ? 'items-start' : 'items-end',
93
- )}
94
- >
95
- <div
96
- className={cn(
97
- 'rounded-agent-md border px-3 py-2 text-sm leading-relaxed',
98
- isAssistant
99
- ? 'border-agent-border bg-agent-muted text-agent-foreground'
100
- : 'border-transparent bg-agent-accent text-agent-accent-foreground',
101
- )}
102
- >
103
- <div className="whitespace-pre-wrap">
104
- {message.content || (isLastAssistant && streaming ? '…' : '')}
198
+ <div className="whitespace-pre-wrap">
199
+ {message.content || (isLastAssistant && isStreaming ? '…' : '')}
200
+ </div>
201
+ {isLastAssistant && isStreaming ? (
202
+ <span
203
+ aria-hidden
204
+ className="ml-0.5 inline-block h-3 w-1.5 align-baseline bg-agent-foreground animate-agent-cursor-blink"
205
+ />
206
+ ) : null}
207
+ </div>
208
+ {Array.isArray(message.toolCalls) && message.toolCalls.length > 0 ? (
209
+ <div className="flex w-full flex-col gap-1.5">
210
+ {message.toolCalls.map((call) => renderToolCall(call, message.toolResult))}
211
+ </div>
212
+ ) : null}
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Input
220
+ //
221
+ // Multi-line textarea with auto-grow, IME-safe keyboard handling, and a
222
+ // send/stop affordance. This is the rich default lifted from
223
+ // deeppath-agent/apps/web/src/components/chat/ChatInput.tsx — minus the
224
+ // agent-pill and settings gear, which live in agent-specific slots the apps
225
+ // can drop into the toolbar via `toolbarLeft`/`toolbarRight` props.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ const MIN_TEXTAREA_PX = 44;
229
+ const MAX_TEXTAREA_PX = 220;
230
+
231
+ const IS_MAC =
232
+ typeof navigator !== 'undefined' &&
233
+ /Mac|iPod|iPhone|iPad/.test(navigator.platform || '');
234
+ const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl';
235
+
236
+ export interface ChatPanelInputHandle {
237
+ focus: () => void;
238
+ focusAtEnd: () => void;
239
+ }
240
+
241
+ export interface ChatPanelInputProps {
242
+ value: string;
243
+ onChange: (next: string) => void;
244
+ onSubmit: () => void | Promise<void>;
245
+ onCancel?: () => void;
246
+ isStreaming?: boolean;
247
+ disabled?: boolean;
248
+ placeholder?: string;
249
+ /**
250
+ * Keyboard mode:
251
+ * - 'cmd-enter' (default for the rich Input): Enter = newline,
252
+ * Cmd/Ctrl+Enter = send. IME-safe.
253
+ * - 'enter': Enter = send, Shift+Enter = newline. Matches the cloud
254
+ * chat product.
255
+ */
256
+ keyMode?: 'cmd-enter' | 'enter';
257
+ /** Optional content slot rendered to the left of the send button. */
258
+ toolbarLeft?: React.ReactNode;
259
+ /** Optional content slot rendered to the right of the textarea. */
260
+ toolbarRight?: React.ReactNode;
261
+ className?: string;
262
+ }
263
+
264
+ export const ChatPanelInput = forwardRef<ChatPanelInputHandle, ChatPanelInputProps>(
265
+ function ChatPanelInput(
266
+ {
267
+ value,
268
+ onChange,
269
+ onSubmit,
270
+ onCancel,
271
+ isStreaming = false,
272
+ disabled = false,
273
+ placeholder = 'Send a message…',
274
+ keyMode = 'cmd-enter',
275
+ toolbarLeft,
276
+ toolbarRight,
277
+ className,
278
+ },
279
+ ref,
280
+ ) {
281
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
282
+
283
+ useImperativeHandle(
284
+ ref,
285
+ () => ({
286
+ focus: () => textareaRef.current?.focus(),
287
+ focusAtEnd: () => {
288
+ const ta = textareaRef.current;
289
+ if (!ta) return;
290
+ ta.focus();
291
+ const len = ta.value.length;
292
+ ta.setSelectionRange(len, len);
293
+ },
294
+ }),
295
+ [],
296
+ );
297
+
298
+ // Auto-grow: reset height first so shrink works when user deletes text.
299
+ useEffect(() => {
300
+ const ta = textareaRef.current;
301
+ if (!ta) return;
302
+ ta.style.height = 'auto';
303
+ const next = Math.min(Math.max(ta.scrollHeight, MIN_TEXTAREA_PX), MAX_TEXTAREA_PX);
304
+ ta.style.height = `${next}px`;
305
+ }, [value]);
306
+
307
+ const trimmed = value.trim();
308
+ const canSend = trimmed.length > 0 && !disabled && !isStreaming;
309
+
310
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
311
+ if (event.nativeEvent.isComposing) return;
312
+ if (keyMode === 'cmd-enter') {
313
+ const isSendCombo =
314
+ event.key === 'Enter' && (event.metaKey || event.ctrlKey);
315
+ if (isSendCombo) {
316
+ event.preventDefault();
317
+ if (canSend) void onSubmit();
318
+ }
319
+ return;
320
+ }
321
+ // keyMode === 'enter'
322
+ if (event.key === 'Enter' && !event.shiftKey) {
323
+ event.preventDefault();
324
+ if (isStreaming) onCancel?.();
325
+ else if (canSend) void onSubmit();
326
+ }
327
+ };
328
+
329
+ const handleSendClick = () => {
330
+ if (isStreaming) {
331
+ onCancel?.();
332
+ return;
333
+ }
334
+ if (canSend) void onSubmit();
335
+ };
336
+
337
+ return (
338
+ <div className={cn('chat-input-container px-3 pb-3 pt-1', className)}>
339
+ <div className="chat-input-box flex flex-col rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm">
340
+ <textarea
341
+ ref={textareaRef}
342
+ value={value}
343
+ onChange={(event) => onChange(event.target.value)}
344
+ onKeyDown={handleKeyDown}
345
+ placeholder={placeholder}
346
+ disabled={disabled}
347
+ rows={1}
348
+ className="block w-full resize-none border-0 bg-transparent px-3 pt-3 text-sm text-agent-foreground outline-none placeholder:text-agent-muted-foreground disabled:opacity-50"
349
+ style={{ minHeight: MIN_TEXTAREA_PX, maxHeight: MAX_TEXTAREA_PX }}
350
+ />
351
+ <div className="flex items-center justify-between gap-2 px-2 pb-2 pt-1">
352
+ <div className="flex items-center gap-1">{toolbarLeft}</div>
353
+ <div className="flex items-center gap-3">
354
+ {toolbarRight}
355
+ <div className="hidden text-[11px] text-agent-muted-foreground/80 sm:block">
356
+ <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
357
+ {MOD_LABEL}
358
+ </kbd>
359
+ <span className="mx-0.5">+</span>
360
+ <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
361
+ {isStreaming ? '.' : 'Enter'}
362
+ </kbd>
105
363
  </div>
106
- {isLastAssistant && streaming ? (
107
- <span
108
- aria-hidden
109
- className="ml-0.5 inline-block h-3 w-1.5 align-baseline bg-agent-foreground animate-agent-cursor-blink"
110
- />
111
- ) : null}
112
- </div>
113
- {Array.isArray(message.toolCalls) && message.toolCalls.length > 0 ? (
114
- <div className="flex w-full flex-col gap-1.5">
115
- {message.toolCalls.map((call) =>
116
- renderToolCall(
117
- call,
118
- // The hook attaches a single `toolResult` to the message;
119
- // production callers that want per-call results should
120
- // override `renderToolCall` and look them up themselves.
121
- message.toolResult,
122
- ),
364
+ <button
365
+ type="button"
366
+ onClick={handleSendClick}
367
+ disabled={!isStreaming && !canSend}
368
+ className={
369
+ isStreaming
370
+ ? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
371
+ : canSend
372
+ ? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
373
+ : 'flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
374
+ }
375
+ aria-label={isStreaming ? 'Stop generating' : 'Send message'}
376
+ title={
377
+ isStreaming
378
+ ? `Stop (${MOD_LABEL}+.)`
379
+ : `Send (${MOD_LABEL}+Enter)`
380
+ }
381
+ >
382
+ {isStreaming ? (
383
+ <span className="block h-3.5 w-3.5 bg-white" />
384
+ ) : (
385
+ <span aria-hidden className="leading-none">↑</span>
123
386
  )}
124
- </div>
125
- ) : null}
387
+ </button>
388
+ </div>
126
389
  </div>
127
390
  </div>
128
- );
129
- },
130
- [renderToolCall],
391
+ </div>
392
+ );
393
+ },
394
+ );
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Empty
398
+ // ---------------------------------------------------------------------------
399
+
400
+ export interface ChatPanelEmptyProps {
401
+ /** Suggested prompts to chip-render. When omitted, the slot is a centred message. */
402
+ prompts?: string[];
403
+ /** Called when the user picks a chip. */
404
+ onSelectPrompt?: (prompt: string) => void;
405
+ title?: string;
406
+ description?: string;
407
+ className?: string;
408
+ }
409
+
410
+ function ChatPanelEmpty(props: ChatPanelEmptyProps) {
411
+ const { prompts, onSelectPrompt, title, description, className } = props;
412
+ return (
413
+ <div className={cn('flex h-full flex-col justify-end gap-3 p-3', className)}>
414
+ {(title || description) && (
415
+ <div className="text-center text-sm text-agent-muted-foreground">
416
+ {title ? (
417
+ <p className="font-medium text-agent-foreground">{title}</p>
418
+ ) : null}
419
+ {description ? <p className="mt-1">{description}</p> : null}
420
+ </div>
421
+ )}
422
+ {Array.isArray(prompts) && prompts.length > 0 ? (
423
+ <div className="flex flex-col items-start gap-2">
424
+ {prompts.map((p) => (
425
+ <button
426
+ key={p}
427
+ type="button"
428
+ onClick={() => onSelectPrompt?.(p)}
429
+ title={p}
430
+ className="max-w-full cursor-pointer truncate rounded-full border border-agent-border bg-agent-muted px-3 py-1.5 text-xs text-agent-muted-foreground transition-all duration-200 hover:bg-agent-accent hover:text-agent-foreground"
431
+ >
432
+ {p}
433
+ </button>
434
+ ))}
435
+ </div>
436
+ ) : null}
437
+ </div>
131
438
  );
439
+ }
132
440
 
441
+ // ---------------------------------------------------------------------------
442
+ // StreamingStatus
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export interface ChatPanelStreamingStatusProps {
446
+ /** Current round number (1-based). */
447
+ round?: number;
448
+ /** Number of tool calls executed so far in this turn. */
449
+ actionCount?: number;
450
+ /**
451
+ * Suppress the status when the bubble has already received content tokens —
452
+ * the cursor blink in the bubble is enough.
453
+ */
454
+ hasContent?: boolean;
455
+ /** Override the auto-derived label. */
456
+ label?: string;
457
+ className?: string;
458
+ }
459
+
460
+ function selectStatusLabel(round: number, actionCount: number): string {
461
+ if (round >= 2 && actionCount === 0) return `Round ${round} · continuing…`;
462
+ if (actionCount > 0) return `Called ${actionCount} tools, analysing…`;
463
+ return 'Thinking…';
464
+ }
465
+
466
+ function ChatPanelStreamingStatus(props: ChatPanelStreamingStatusProps) {
467
+ const {
468
+ round = 1,
469
+ actionCount = 0,
470
+ hasContent = false,
471
+ label,
472
+ className,
473
+ } = props;
474
+ if (hasContent) return null;
475
+ const text = label ?? selectStatusLabel(round, actionCount);
133
476
  return (
134
477
  <div
478
+ role="status"
479
+ aria-live="polite"
480
+ aria-atomic="true"
135
481
  className={cn(
136
- 'flex h-full w-full flex-col bg-agent-canvas text-agent-foreground',
482
+ 'my-2 inline-flex items-center gap-2 text-xs text-agent-muted-foreground',
137
483
  className,
138
484
  )}
139
485
  >
140
- {header}
141
- <div className="flex-1 overflow-hidden">
142
- <MessageList
143
- messages={messages}
144
- isStreaming={isStreaming}
145
- renderMessage={renderMessage}
146
- emptyState={emptyState}
147
- />
148
- </div>
149
- <form
150
- className="flex items-center gap-2 border-t border-agent-border bg-agent-canvas px-3 py-2"
151
- onSubmit={handleSubmit}
152
- >
153
- <input
154
- type="text"
155
- value={draft}
156
- onChange={(e) => setDraft(e.target.value)}
157
- placeholder={inputPlaceholder}
158
- disabled={disabled}
159
- className={cn(
160
- 'flex-1 rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 text-sm text-agent-foreground placeholder:text-agent-muted-foreground',
161
- 'focus:border-agent-accent focus:outline-none focus:ring-1 focus:ring-agent-accent',
162
- disabled && 'opacity-50',
163
- )}
164
- />
165
- {isStreaming && onCancel ? (
166
- <button
167
- type="button"
168
- onClick={onCancel}
169
- className="rounded-agent-md border border-agent-border px-3 py-2 text-sm text-agent-foreground hover:bg-agent-muted"
170
- >
171
- Stop
172
- </button>
173
- ) : (
174
- <button
175
- type="submit"
176
- disabled={disabled || !draft.trim()}
177
- className={cn(
178
- 'rounded-agent-md bg-agent-accent px-3 py-2 text-sm font-medium text-agent-accent-foreground',
179
- (disabled || !draft.trim()) && 'opacity-50',
180
- )}
181
- >
182
- Send
183
- </button>
184
- )}
185
- </form>
486
+ <span className="inline-flex items-center gap-1">
487
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:0ms]" />
488
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:150ms]" />
489
+ <span className="h-1 w-1 animate-pulse rounded-full bg-agent-foreground/40 [animation-delay:300ms]" />
490
+ </span>
491
+ <span>{text}</span>
492
+ {round > 1 ? (
493
+ <span className="rounded-full border border-agent-border bg-agent-muted/40 px-1.5 py-[1px] font-mono text-[10px] text-agent-muted-foreground">
494
+ round {round}
495
+ </span>
496
+ ) : null}
186
497
  </div>
187
498
  );
188
499
  }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Backwards-compat monolithic alias
503
+ //
504
+ // The 0.2.x signature `<ChatPanel messages onSubmit isStreaming … />` keeps
505
+ // working — we just wrap it with the compound primitives internally. Existing
506
+ // /dev/framework-preview, mock stories, and the deeppath-agent LocalChatPanel
507
+ // continue to render without churn.
508
+ // ---------------------------------------------------------------------------
509
+
510
+ export interface ChatPanelProps {
511
+ messages: ChatMessage[];
512
+ isStreaming?: boolean;
513
+ onSubmit: (input: { content: string }) => void | Promise<void>;
514
+ onCancel?: () => void;
515
+ className?: string;
516
+ inputPlaceholder?: string;
517
+ header?: React.ReactNode;
518
+ renderToolCall?: (call: ToolCall, result?: ToolResult) => React.ReactNode;
519
+ renderMessage?: (props: MessageRendererProps) => React.ReactNode;
520
+ emptyState?: React.ReactNode;
521
+ disabled?: boolean;
522
+ }
523
+
524
+ function ChatPanelMonolith(props: ChatPanelProps) {
525
+ const {
526
+ messages,
527
+ isStreaming = false,
528
+ onSubmit,
529
+ onCancel,
530
+ className,
531
+ inputPlaceholder,
532
+ header,
533
+ renderToolCall,
534
+ renderMessage,
535
+ emptyState,
536
+ disabled = false,
537
+ } = props;
538
+ const [draft, setDraft] = useState('');
539
+ const handleSubmit = useCallback(async () => {
540
+ const trimmed = draft.trim();
541
+ if (!trimmed || disabled) return;
542
+ setDraft('');
543
+ await onSubmit({ content: trimmed });
544
+ }, [draft, disabled, onSubmit]);
545
+
546
+ return (
547
+ <ChatPanelRoot className={className}>
548
+ {header ? <ChatPanelHeader>{header}</ChatPanelHeader> : null}
549
+ <ChatPanelMessages
550
+ messages={messages}
551
+ isStreaming={isStreaming}
552
+ renderMessage={renderMessage}
553
+ renderToolCall={renderToolCall}
554
+ emptyState={emptyState}
555
+ />
556
+ <ChatPanelInput
557
+ value={draft}
558
+ onChange={setDraft}
559
+ onSubmit={handleSubmit}
560
+ onCancel={onCancel}
561
+ isStreaming={isStreaming}
562
+ disabled={disabled}
563
+ placeholder={inputPlaceholder}
564
+ keyMode="enter"
565
+ />
566
+ </ChatPanelRoot>
567
+ );
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Connected
572
+ //
573
+ // The wave-4 deliverable: a `ChatPanel.Connected` shortcut that reads from a
574
+ // `ChatSessionProvider` mounted above, so an embedding looks like:
575
+ //
576
+ // <ChatSessionProvider value={useChatSession({ transport })}>
577
+ // <ChatPanel.Connected emptyPrompts={['Hello', 'Resume']} />
578
+ // </ChatSessionProvider>
579
+ //
580
+ // Apps that haven't migrated to a provider continue to pass props explicitly
581
+ // via the compound parts.
582
+ // ---------------------------------------------------------------------------
583
+
584
+ export interface ChatPanelConnectedProps {
585
+ className?: string;
586
+ header?: React.ReactNode;
587
+ emptyPrompts?: string[];
588
+ emptyTitle?: string;
589
+ emptyDescription?: string;
590
+ inputPlaceholder?: string;
591
+ renderMessage?: (props: MessageRendererProps) => React.ReactNode;
592
+ renderToolCall?: (call: ToolCall, result?: ToolResult) => React.ReactNode;
593
+ /** Apply the panel's own background/foreground tokens (default). */
594
+ styled?: boolean;
595
+ }
596
+
597
+ function ChatPanelConnected(props: ChatPanelConnectedProps) {
598
+ const {
599
+ className,
600
+ header,
601
+ emptyPrompts,
602
+ emptyTitle,
603
+ emptyDescription,
604
+ inputPlaceholder,
605
+ renderMessage,
606
+ renderToolCall,
607
+ styled = true,
608
+ } = props;
609
+ const session = useChatSessionContext();
610
+ const { composer } = session;
611
+
612
+ const emptyState =
613
+ emptyPrompts || emptyTitle || emptyDescription ? (
614
+ <ChatPanelEmpty
615
+ prompts={emptyPrompts}
616
+ title={emptyTitle}
617
+ description={emptyDescription}
618
+ onSelectPrompt={(text) => {
619
+ composer.setValue(text);
620
+ void composer.submit();
621
+ }}
622
+ />
623
+ ) : undefined;
624
+
625
+ return (
626
+ <ChatPanelRoot className={className} unstyled={!styled}>
627
+ {header ? <ChatPanelHeader>{header}</ChatPanelHeader> : null}
628
+ <ChatPanelMessages
629
+ messages={session.messages}
630
+ isStreaming={session.isStreaming}
631
+ renderMessage={renderMessage}
632
+ renderToolCall={renderToolCall}
633
+ emptyState={emptyState}
634
+ />
635
+ <ChatPanelInput
636
+ value={composer.value}
637
+ onChange={composer.setValue}
638
+ onSubmit={composer.submit}
639
+ onCancel={composer.cancel}
640
+ isStreaming={session.isStreaming}
641
+ placeholder={inputPlaceholder}
642
+ />
643
+ </ChatPanelRoot>
644
+ );
645
+ }
646
+
647
+ // `ChatPanel` is the monolith (backwards-compat); the compound parts hang
648
+ // off it as named attributes. This mirrors Radix's pattern so consumers can
649
+ // keep `<ChatPanel ...>` working OR migrate to `<ChatPanel.Root>…</ChatPanel.Root>`
650
+ // in their own time.
651
+ const ChatPanelCompound = Object.assign(ChatPanelMonolith, {
652
+ Root: ChatPanelRoot,
653
+ Header: ChatPanelHeader,
654
+ Messages: ChatPanelMessages,
655
+ Input: ChatPanelInput,
656
+ Empty: ChatPanelEmpty,
657
+ StreamingStatus: ChatPanelStreamingStatus,
658
+ Connected: ChatPanelConnected,
659
+ });
660
+
661
+ export { ChatPanelCompound as ChatPanel };
662
+ export type { MessageRendererProps };