@refraction-ui/astro 0.8.0 → 0.9.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 (96) hide show
  1. package/dist/analytics/index.ts +5 -0
  2. package/dist/astro-accordion/Accordion.stories.ts +12 -0
  3. package/dist/astro-ai/Ai.stories.ts +12 -0
  4. package/dist/astro-analytics/Analytics.stories.ts +12 -0
  5. package/dist/astro-animated-text/AnimatedText.stories.ts +12 -0
  6. package/dist/astro-app-shell/AppShell.stories.ts +12 -0
  7. package/dist/astro-auth/Auth.stories.ts +12 -0
  8. package/dist/astro-avatar/Avatar.stories.ts +12 -0
  9. package/dist/astro-avatar-group/AvatarGroup.stories.ts +12 -0
  10. package/dist/astro-badge/Badge.stories.ts +16 -0
  11. package/dist/astro-bottom-nav/BottomNav.stories.ts +12 -0
  12. package/dist/astro-breadcrumbs/Breadcrumbs.stories.ts +12 -0
  13. package/dist/astro-button/Button.stories.ts +12 -0
  14. package/dist/astro-calendar/Calendar.stories.ts +12 -0
  15. package/dist/astro-callout/Callout.stories.ts +12 -0
  16. package/dist/astro-card/Card.stories.ts +12 -0
  17. package/dist/astro-card-grid/CardGrid.stories.ts +12 -0
  18. package/dist/astro-carousel/Carousel.stories.ts +12 -0
  19. package/dist/astro-charts/Charts.stories.ts +12 -0
  20. package/dist/astro-checkbox/Checkbox.stories.ts +12 -0
  21. package/dist/astro-code-block/CodeBlock.stories.ts +12 -0
  22. package/dist/astro-code-editor/CodeEditor.stories.ts +12 -0
  23. package/dist/astro-collapsible/Collapsible.stories.ts +12 -0
  24. package/dist/astro-command/Command.stories.ts +12 -0
  25. package/dist/astro-command-input/CommandInput.stories.ts +12 -0
  26. package/dist/astro-content-protection/ContentProtection.stories.ts +12 -0
  27. package/dist/astro-conversation/Chat.astro +208 -0
  28. package/dist/astro-conversation/Conversation.stories.ts +12 -0
  29. package/dist/astro-conversation/index.ts +29 -0
  30. package/dist/astro-cookie-consent/CookieConsent.astro +141 -0
  31. package/dist/astro-cookie-consent/index.ts +12 -0
  32. package/dist/astro-data-table/DataTable.stories.ts +12 -0
  33. package/dist/astro-date-picker/DatePicker.stories.ts +12 -0
  34. package/dist/astro-device-frame/DeviceFrame.stories.ts +12 -0
  35. package/dist/astro-dialog/Dialog.stories.ts +12 -0
  36. package/dist/astro-diff-viewer/DiffViewer.stories.ts +12 -0
  37. package/dist/astro-dropdown-menu/DropdownMenu.stories.ts +12 -0
  38. package/dist/astro-emoji-picker/EmojiPicker.stories.ts +12 -0
  39. package/dist/astro-feedback-dialog/FeedbackDialog.stories.ts +12 -0
  40. package/dist/astro-file-tree/FileTree.stories.ts +12 -0
  41. package/dist/astro-file-upload/FileUpload.stories.ts +12 -0
  42. package/dist/astro-footer/Footer.stories.ts +12 -0
  43. package/dist/astro-icon-system/IconSystem.stories.ts +12 -0
  44. package/dist/astro-inline-editor/InlineEditor.stories.ts +12 -0
  45. package/dist/astro-input/Input.stories.ts +12 -0
  46. package/dist/astro-input-group/InputGroup.stories.ts +12 -0
  47. package/dist/astro-install-prompt/InstallPrompt.stories.ts +12 -0
  48. package/dist/astro-keyboard-shortcut/KeyboardShortcut.stories.ts +12 -0
  49. package/dist/astro-language-selector/LanguageSelector.stories.ts +12 -0
  50. package/dist/astro-link-card/LinkCard.stories.ts +12 -0
  51. package/dist/astro-logger/Logger.stories.ts +12 -0
  52. package/dist/astro-markdown-renderer/MarkdownRenderer.stories.ts +12 -0
  53. package/dist/astro-mobile-nav/MobileNav.stories.ts +12 -0
  54. package/dist/astro-navbar/Navbar.stories.ts +12 -0
  55. package/dist/astro-otp-input/OtpInput.stories.ts +12 -0
  56. package/dist/astro-pagination/Pagination.stories.ts +12 -0
  57. package/dist/astro-payment/Payment.stories.ts +12 -0
  58. package/dist/astro-popover/Popover.stories.ts +12 -0
  59. package/dist/astro-presence-indicator/PresenceIndicator.stories.ts +12 -0
  60. package/dist/astro-progress-display/ProgressDisplay.stories.ts +12 -0
  61. package/dist/astro-radio/Radio.stories.ts +12 -0
  62. package/dist/astro-reaction-bar/ReactionBar.stories.ts +12 -0
  63. package/dist/astro-resizable-layout/ResizableLayout.stories.ts +12 -0
  64. package/dist/astro-rich-editor/RichEditor.stories.ts +12 -0
  65. package/dist/astro-search-bar/SearchBar.stories.ts +12 -0
  66. package/dist/astro-select/Select.stories.ts +12 -0
  67. package/dist/astro-sidebar/Sidebar.stories.ts +12 -0
  68. package/dist/astro-skeleton/Skeleton.stories.ts +12 -0
  69. package/dist/astro-skip-to-content/SkipToContent.stories.ts +12 -0
  70. package/dist/astro-slide-viewer/SlideViewer.stories.ts +12 -0
  71. package/dist/astro-status-indicator/StatusIndicator.stories.ts +12 -0
  72. package/dist/astro-steps/Steps.stories.ts +12 -0
  73. package/dist/astro-switch/Switch.stories.ts +12 -0
  74. package/dist/astro-table-of-contents/TableOfContents.stories.ts +12 -0
  75. package/dist/astro-tabs/Tabs.stories.ts +12 -0
  76. package/dist/astro-textarea/Textarea.stories.ts +12 -0
  77. package/dist/astro-theme/Theme.stories.ts +12 -0
  78. package/dist/astro-thread-view/ThreadView.stories.ts +12 -0
  79. package/dist/astro-toast/Toast.stories.ts +12 -0
  80. package/dist/astro-tooltip/Tooltip.stories.ts +12 -0
  81. package/dist/astro-version-selector/VersionSelector.stories.ts +12 -0
  82. package/dist/astro-video-player/VideoPlayer.stories.ts +12 -0
  83. package/dist/astro-voice-pill/VoicePill.stories.ts +12 -0
  84. package/dist/astro-waveform/Waveform.stories.ts +12 -0
  85. package/dist/conversation/conversation.ts +379 -0
  86. package/dist/conversation/index.ts +30 -0
  87. package/dist/conversation/selectors.ts +58 -0
  88. package/dist/conversation/types.ts +222 -0
  89. package/dist/cookie-consent/cookie-consent.ts +132 -0
  90. package/dist/cookie-consent/index.ts +10 -0
  91. package/dist/cookie-consent/types.ts +72 -0
  92. package/dist/index.ts +2 -0
  93. package/dist/shared/library-origin-error.ts +1 -2
  94. package/dist/shared/types.ts +1 -1
  95. package/dist/theme/theme-script.ts +1 -1
  96. package/package.json +3 -1
@@ -0,0 +1,12 @@
1
+ import Component from './SkipToContent.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/SkipToContent',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './SlideViewer.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/SlideViewer',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './StatusIndicator.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/StatusIndicator',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Steps.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Steps',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Switch.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Switch',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './TableOfContents.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/TableOfContents',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Tabs.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Tabs',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Textarea.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Textarea',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './ThemeScript.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Theme',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './ThreadView.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/ThreadView',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Toast.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Toast',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Tooltip.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Tooltip',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './VersionSelector.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/VersionSelector',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './VideoPlayer.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/VideoPlayer',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './VoicePill.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/VoicePill',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,12 @@
1
+ import Component from './Waveform.astro'
2
+
3
+ // Auto-generated baseline story. Renders the component with empty args;
4
+ // components that require props show a render error (enrich by hand as needed).
5
+ const meta = {
6
+ title: 'Astro/Waveform',
7
+ component: Component,
8
+ }
9
+
10
+ export default meta
11
+
12
+ export const Default = { args: {} }
@@ -0,0 +1,379 @@
1
+ import { generateId } from '../shared/index.ts'
2
+ import { rootIdOf, selectRoots, selectThreadMessages } from './selectors.js'
3
+ import type {
4
+ ChatAuthor,
5
+ ChatMessage,
6
+ Conversation,
7
+ ConversationAPI,
8
+ ConversationConfig,
9
+ ConversationState,
10
+ SendOptions,
11
+ ThreadingMode,
12
+ } from './types.js'
13
+
14
+ const DEFAULT_ASSISTANT: ChatAuthor = { id: 'assistant', name: 'Assistant' }
15
+ const DEFAULT_USER: ChatAuthor = { id: 'user', name: 'You' }
16
+ const DEFAULT_TITLE = 'New conversation'
17
+
18
+ /** Default title: first line, trimmed to ~48 chars. */
19
+ function defaultGenerateTitle(firstMessage: string): string {
20
+ const firstLine = firstMessage.split('\n', 1)[0]!.trim()
21
+ if (firstLine.length <= 48) return firstLine || DEFAULT_TITLE
22
+ return `${firstLine.slice(0, 47).trimEnd()}…`
23
+ }
24
+
25
+ /**
26
+ * createConversation — headless multi-conversation chat store with reply-threads,
27
+ * reactions, edit/delete, and streaming. Backend-agnostic: provide a
28
+ * {@link ChatTransport} and `sendMessage` will optimistically append the user
29
+ * message, stream the reply, and expose retry/stop. Without a transport it is a
30
+ * pure local message log.
31
+ */
32
+ export function createConversation(config: ConversationConfig = {}): ConversationAPI {
33
+ const assistant = config.assistant ?? DEFAULT_ASSISTANT
34
+ const currentUser = config.currentUser ?? DEFAULT_USER
35
+ const generateTitle = config.generateTitle ?? defaultGenerateTitle
36
+
37
+ let transport = config.transport
38
+ let threadingMode: ThreadingMode = config.threadingMode ?? 'inline'
39
+ const conversations = new Map<string, Conversation>()
40
+ const messagesByConv = new Map<string, ChatMessage[]>()
41
+ let activeConversationId: string | null = config.activeConversationId ?? null
42
+ let openThreadRootId: string | null = null
43
+ let status: ConversationState['status'] = 'idle'
44
+ let error: string | null = null
45
+ let abortController: AbortController | null = null
46
+
47
+ // Seed
48
+ for (const c of config.conversations ?? []) {
49
+ conversations.set(c.id, c)
50
+ messagesByConv.set(c.id, [])
51
+ }
52
+ for (const [conversationId, msgs] of Object.entries(config.messages ?? {})) {
53
+ messagesByConv.set(conversationId, [...msgs])
54
+ }
55
+ if (activeConversationId === null && config.conversations?.length) {
56
+ activeConversationId = config.conversations[0]!.id
57
+ }
58
+
59
+ // — subscription —
60
+ const listeners = new Set<() => void>()
61
+ let snapshot: ConversationState = buildSnapshot()
62
+
63
+ function buildSnapshot(): ConversationState {
64
+ return {
65
+ conversations: orderedConversations(),
66
+ activeConversationId,
67
+ messages: activeConversationId ? (messagesByConv.get(activeConversationId) ?? []) : [],
68
+ openThreadRootId,
69
+ threadingMode,
70
+ status,
71
+ error,
72
+ }
73
+ }
74
+
75
+ function emit(): void {
76
+ snapshot = buildSnapshot()
77
+ for (const l of listeners) l()
78
+ }
79
+
80
+ function orderedConversations(): Conversation[] {
81
+ return [...conversations.values()].sort(
82
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
83
+ )
84
+ }
85
+
86
+ function touch(conversationId: string): void {
87
+ const c = conversations.get(conversationId)
88
+ if (c) conversations.set(conversationId, { ...c, updatedAt: new Date() })
89
+ }
90
+
91
+ function ensureActiveConversation(opts?: SendOptions): string {
92
+ if (opts?.conversationId) {
93
+ if (!conversations.has(opts.conversationId)) createConversationInternal({}, opts.conversationId)
94
+ return opts.conversationId
95
+ }
96
+ if (activeConversationId && conversations.has(activeConversationId)) return activeConversationId
97
+ return createConversationInternal({}).id
98
+ }
99
+
100
+ function createConversationInternal(
101
+ opts: { title?: string; metadata?: Record<string, unknown> },
102
+ id?: string,
103
+ ): Conversation {
104
+ const now = new Date()
105
+ const conversation: Conversation = {
106
+ id: id ?? generateId('rfr-conv'),
107
+ title: opts.title ?? DEFAULT_TITLE,
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ metadata: opts.metadata,
111
+ }
112
+ conversations.set(conversation.id, conversation)
113
+ messagesByConv.set(conversation.id, [])
114
+ activeConversationId = conversation.id
115
+ return conversation
116
+ }
117
+
118
+ /** Run a transport stream into the given assistant message. */
119
+ async function streamReply(
120
+ conversationId: string,
121
+ assistantMsg: ChatMessage,
122
+ userMsg: ChatMessage,
123
+ history: ChatMessage[],
124
+ ): Promise<void> {
125
+ abortController = new AbortController()
126
+ status = 'streaming'
127
+ emit()
128
+
129
+ try {
130
+ const stream = transport!.send({
131
+ conversationId,
132
+ message: userMsg,
133
+ history,
134
+ parentId: assistantMsg.parentId,
135
+ signal: abortController.signal,
136
+ })
137
+ for await (const chunk of stream) {
138
+ // Apply the delivered chunk before honoring an abort, so a `stop()` mid
139
+ // stream keeps the partial reply rather than discarding the last token.
140
+ if (chunk.content !== undefined) assistantMsg.content = chunk.content
141
+ if (chunk.delta) assistantMsg.content += chunk.delta
142
+ if (chunk.metadata) {
143
+ assistantMsg.metadata = { ...assistantMsg.metadata, ...chunk.metadata }
144
+ }
145
+ emit()
146
+ if (abortController.signal.aborted) break
147
+ }
148
+ assistantMsg.status = 'sent'
149
+ status = 'idle'
150
+ error = null
151
+ } catch (err) {
152
+ if (abortController.signal.aborted) {
153
+ assistantMsg.status = 'sent'
154
+ status = 'idle'
155
+ } else {
156
+ const message = err instanceof Error ? err.message : String(err)
157
+ assistantMsg.status = 'error'
158
+ assistantMsg.error = message
159
+ status = 'error'
160
+ error = message
161
+ }
162
+ } finally {
163
+ abortController = null
164
+ touch(conversationId)
165
+ emit()
166
+ }
167
+ }
168
+
169
+ /** History the transport sees: the thread when replying, else the main timeline. */
170
+ function historyFor(list: ChatMessage[], parentId: string | undefined): ChatMessage[] {
171
+ const scope = parentId
172
+ ? selectThreadMessages(list, parentId)
173
+ : selectRoots(list)
174
+ return scope.filter((m) => m.status !== 'error')
175
+ }
176
+
177
+ const api: ConversationAPI = {
178
+ getState() {
179
+ return snapshot
180
+ },
181
+
182
+ subscribe(listener) {
183
+ listeners.add(listener)
184
+ return () => listeners.delete(listener)
185
+ },
186
+
187
+ newConversation(opts) {
188
+ const conversation = createConversationInternal(opts ?? {})
189
+ openThreadRootId = null
190
+ emit()
191
+ return conversation
192
+ },
193
+
194
+ selectConversation(conversationId) {
195
+ if (!conversations.has(conversationId) || activeConversationId === conversationId) return
196
+ activeConversationId = conversationId
197
+ openThreadRootId = null
198
+ emit()
199
+ },
200
+
201
+ deleteConversation(conversationId) {
202
+ if (!conversations.has(conversationId)) return
203
+ conversations.delete(conversationId)
204
+ messagesByConv.delete(conversationId)
205
+ if (activeConversationId === conversationId) {
206
+ activeConversationId = orderedConversations()[0]?.id ?? null
207
+ openThreadRootId = null
208
+ }
209
+ emit()
210
+ },
211
+
212
+ renameConversation(conversationId, title) {
213
+ const c = conversations.get(conversationId)
214
+ if (!c) return
215
+ conversations.set(conversationId, { ...c, title })
216
+ emit()
217
+ },
218
+
219
+ appendMessage(message) {
220
+ const list = messagesByConv.get(message.conversationId)
221
+ if (!list) return
222
+ list.push(message)
223
+ touch(message.conversationId)
224
+ emit()
225
+ },
226
+
227
+ editMessage(messageId, content) {
228
+ if (!activeConversationId) return
229
+ const list = messagesByConv.get(activeConversationId)!
230
+ const msg = list.find((m) => m.id === messageId)
231
+ if (!msg) return
232
+ msg.content = content
233
+ msg.edited = true
234
+ emit()
235
+ },
236
+
237
+ deleteMessage(messageId) {
238
+ if (!activeConversationId) return
239
+ const list = messagesByConv.get(activeConversationId)!
240
+ const msg = list.find((m) => m.id === messageId)
241
+ if (!msg) return
242
+ const removeIds = new Set<string>([messageId])
243
+ // Deleting a thread root removes its replies too.
244
+ if (!msg.parentId) for (const r of list) if (r.parentId === messageId) removeIds.add(r.id)
245
+ const next = list.filter((m) => !removeIds.has(m.id))
246
+ messagesByConv.set(activeConversationId, next)
247
+ if (openThreadRootId && removeIds.has(openThreadRootId)) openThreadRootId = null
248
+ emit()
249
+ },
250
+
251
+ react(messageId, emoji) {
252
+ if (!activeConversationId) return
253
+ const list = messagesByConv.get(activeConversationId)!
254
+ const msg = list.find((m) => m.id === messageId)
255
+ if (!msg) return
256
+ const reactions = [...(msg.reactions ?? [])]
257
+ const idx = reactions.findIndex((r) => r.emoji === emoji)
258
+ if (idx === -1) {
259
+ reactions.push({ emoji, count: 1, userReacted: true })
260
+ } else {
261
+ const r = reactions[idx]!
262
+ if (r.userReacted) {
263
+ const count = r.count - 1
264
+ if (count <= 0) reactions.splice(idx, 1)
265
+ else reactions[idx] = { ...r, count, userReacted: false }
266
+ } else {
267
+ reactions[idx] = { ...r, count: r.count + 1, userReacted: true }
268
+ }
269
+ }
270
+ msg.reactions = reactions
271
+ emit()
272
+ },
273
+
274
+ async sendMessage(content, opts) {
275
+ const trimmed = content.trim()
276
+ if (!trimmed && !opts?.attachments?.length) return
277
+
278
+ const conversationId = ensureActiveConversation(opts)
279
+ const list = messagesByConv.get(conversationId)!
280
+ const parentId = opts?.replyTo ? rootIdOf(list, opts.replyTo) : undefined
281
+ const isFirstRoot = !parentId && selectRoots(list).length === 0
282
+
283
+ const userMsg: ChatMessage = {
284
+ id: generateId('rfr-msg'),
285
+ conversationId,
286
+ role: 'user',
287
+ author: currentUser,
288
+ content: trimmed,
289
+ timestamp: new Date(),
290
+ status: 'sent',
291
+ parentId,
292
+ attachments: opts?.attachments,
293
+ metadata: opts?.metadata,
294
+ }
295
+ list.push(userMsg)
296
+
297
+ const conversation = conversations.get(conversationId)!
298
+ if (isFirstRoot && conversation.title === DEFAULT_TITLE) {
299
+ conversations.set(conversationId, { ...conversation, title: generateTitle(trimmed) })
300
+ }
301
+ touch(conversationId)
302
+
303
+ if (!transport) {
304
+ status = 'idle'
305
+ emit()
306
+ return
307
+ }
308
+
309
+ // history excludes the just-pushed user message
310
+ const history = historyFor(list, parentId).filter((m) => m.id !== userMsg.id)
311
+ const assistantMsg: ChatMessage = {
312
+ id: generateId('rfr-msg'),
313
+ conversationId,
314
+ role: 'assistant',
315
+ author: assistant,
316
+ content: '',
317
+ timestamp: new Date(),
318
+ status: 'streaming',
319
+ parentId,
320
+ }
321
+ list.push(assistantMsg)
322
+ await streamReply(conversationId, assistantMsg, userMsg, history)
323
+ },
324
+
325
+ async retryLast() {
326
+ if (!activeConversationId || !transport) return
327
+ const list = messagesByConv.get(activeConversationId)!
328
+ const last = list[list.length - 1]
329
+ const parentId = last?.parentId
330
+ // Drop a trailing failed assistant message, if any.
331
+ if (last && last.role === 'assistant' && last.status === 'error') list.pop()
332
+
333
+ const scope = parentId ? selectThreadMessages(list, parentId) : selectRoots(list)
334
+ const lastUser = [...scope].reverse().find((m) => m.role === 'user')
335
+ if (!lastUser) return
336
+
337
+ const history = historyFor(list, parentId)
338
+ const assistantMsg: ChatMessage = {
339
+ id: generateId('rfr-msg'),
340
+ conversationId: activeConversationId,
341
+ role: 'assistant',
342
+ author: assistant,
343
+ content: '',
344
+ timestamp: new Date(),
345
+ status: 'streaming',
346
+ parentId,
347
+ }
348
+ list.push(assistantMsg)
349
+ await streamReply(activeConversationId, assistantMsg, lastUser, history)
350
+ },
351
+
352
+ stop() {
353
+ abortController?.abort()
354
+ },
355
+
356
+ openThread(rootId) {
357
+ openThreadRootId = rootId
358
+ emit()
359
+ },
360
+
361
+ closeThread() {
362
+ if (openThreadRootId === null) return
363
+ openThreadRootId = null
364
+ emit()
365
+ },
366
+
367
+ setThreadingMode(mode) {
368
+ if (threadingMode === mode) return
369
+ threadingMode = mode
370
+ emit()
371
+ },
372
+
373
+ setTransport(next) {
374
+ transport = next
375
+ },
376
+ }
377
+
378
+ return api
379
+ }
@@ -0,0 +1,30 @@
1
+ export { createConversation } from './conversation.js'
2
+
3
+ export {
4
+ findMessage,
5
+ rootIdOf,
6
+ selectRoots,
7
+ selectReplies,
8
+ selectThreadMessages,
9
+ getReplyCount,
10
+ selectMainTimeline,
11
+ selectTimelineFromState,
12
+ } from './selectors.js'
13
+
14
+ export type {
15
+ ChatAuthor,
16
+ ChatRole,
17
+ MessageStatus,
18
+ ThreadingMode,
19
+ MessageReaction,
20
+ MessageAttachment,
21
+ ChatMessage,
22
+ Conversation,
23
+ SendContext,
24
+ TransportChunk,
25
+ ChatTransport,
26
+ ConversationState,
27
+ ConversationConfig,
28
+ SendOptions,
29
+ ConversationAPI,
30
+ } from './types.js'
@@ -0,0 +1,58 @@
1
+ import type { ChatMessage, ConversationState, ThreadingMode } from './types.js'
2
+
3
+ /** Sort by timestamp ascending (oldest first). */
4
+ function byTime(a: ChatMessage, b: ChatMessage): number {
5
+ return a.timestamp.getTime() - b.timestamp.getTime()
6
+ }
7
+
8
+ /** Find a message by id. */
9
+ export function findMessage(messages: ChatMessage[], id: string): ChatMessage | undefined {
10
+ return messages.find((m) => m.id === id)
11
+ }
12
+
13
+ /** Resolve a message id to its thread root (itself if it's already a root). */
14
+ export function rootIdOf(messages: ChatMessage[], id: string): string {
15
+ const m = findMessage(messages, id)
16
+ if (!m) return id
17
+ return m.parentId ?? m.id
18
+ }
19
+
20
+ /** Root (main-timeline) messages — those not belonging to a reply-thread. */
21
+ export function selectRoots(messages: ChatMessage[]): ChatMessage[] {
22
+ return messages.filter((m) => !m.parentId).sort(byTime)
23
+ }
24
+
25
+ /** Replies belonging to a thread root, oldest first (excludes the root itself). */
26
+ export function selectReplies(messages: ChatMessage[], rootId: string): ChatMessage[] {
27
+ return messages.filter((m) => m.parentId === rootId).sort(byTime)
28
+ }
29
+
30
+ /** A full thread: root message followed by its replies. */
31
+ export function selectThreadMessages(messages: ChatMessage[], rootId: string): ChatMessage[] {
32
+ const root = findMessage(messages, rootId)
33
+ const replies = selectReplies(messages, rootId)
34
+ return root ? [root, ...replies] : replies
35
+ }
36
+
37
+ /** Number of replies in a thread. */
38
+ export function getReplyCount(messages: ChatMessage[], rootId: string): number {
39
+ return messages.reduce((n, m) => (m.parentId === rootId ? n + 1 : n), 0)
40
+ }
41
+
42
+ /**
43
+ * The main timeline for the active conversation, honoring the threading mode:
44
+ * - `'inline'`: every message in chronological order (replies included).
45
+ * - `'panel'`: only root messages (replies hidden behind their thread).
46
+ */
47
+ export function selectMainTimeline(
48
+ messages: ChatMessage[],
49
+ mode: ThreadingMode,
50
+ ): ChatMessage[] {
51
+ if (mode === 'panel') return selectRoots(messages)
52
+ return [...messages].sort(byTime)
53
+ }
54
+
55
+ /** Convenience: main timeline from a store snapshot. */
56
+ export function selectTimelineFromState(state: ConversationState): ChatMessage[] {
57
+ return selectMainTimeline(state.messages, state.threadingMode)
58
+ }