@refraction-ui/astro 0.15.2 → 0.16.1

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,337 @@
1
+ /**
2
+ * Public types for the headless chat composer core.
3
+ *
4
+ * The composer models a single growing message buffer (flat string + selection)
5
+ * with inline triggers (@mention, /command, :emoji:, #tag, custom), committed
6
+ * atomic tokens, staged attachments, and one optimistic submit. Framework
7
+ * adapters (React/Astro/Flutter) wrap this contract without re-implementing
8
+ * any behavior.
9
+ */
10
+
11
+ import type { MessageAttachment } from '../conversation/index.ts'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Triggers & suggestions
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Where a trigger symbol is allowed to arm. */
18
+ export type ComposerTriggerScope = 'anywhere' | 'startOfLine' | 'startOfMessage'
19
+
20
+ /** A candidate shown in the suggestion menu for an armed trigger. */
21
+ export interface ComposerCandidate {
22
+ id: string
23
+ display: string
24
+ subtitle?: string
25
+ metadata?: Record<string, unknown>
26
+ }
27
+
28
+ export type ComposerTriggerResolver = (
29
+ query: string,
30
+ ) => ComposerCandidate[] | Promise<ComposerCandidate[]>
31
+
32
+ /**
33
+ * A trigger is configuration, not code — the engine is symbol-agnostic and
34
+ * never special-cases '@' / '/' / ':' in control flow.
35
+ */
36
+ export interface ComposerTrigger {
37
+ id: string
38
+ /** Trigger symbol; any length ('@', '/', '#', '!!'). */
39
+ symbol: string
40
+ /** Default 'anywhere'. Slash-style commands use 'startOfMessage'. */
41
+ scope?: ComposerTriggerScope
42
+ /** Extra validation for the query; a violating query closes the trigger. */
43
+ queryPattern?: RegExp
44
+ /** Queries longer than this silently cancel the trigger. Default 40. */
45
+ maxQueryLength?: number
46
+ /** Default true. '#'-style triggers set false so '#weekend trip' stays armed. */
47
+ closeOnSpace?: boolean
48
+ /** Default false. Trades away email/URL protection — must be justified. */
49
+ allowMidWord?: boolean
50
+ /** Additional boundary characters allowed before the symbol (e.g. '(' or '"'). */
51
+ extraBoundaryChars?: string[]
52
+ /** Resolver debounce in ms. Default 0 (sync/local resolvers). */
53
+ debounceMs?: number
54
+ /** Visible slice size for the adapter's menu. Default 6. */
55
+ maxVisibleResults?: number
56
+ /** Whether ArrowUp/Down wrap around the ends. Default true. */
57
+ wrapNavigation?: boolean
58
+ /**
59
+ * Builds the committed token's display text. Defaults to `symbol + display`
60
+ * ('@Jordan Lee'); the emoji recipe returns the unicode itself.
61
+ */
62
+ toDisplay?: (candidate: ComposerCandidate) => string
63
+ resolve: ComposerTriggerResolver
64
+ }
65
+
66
+ /** A committed, atomic inline unit (identity + frozen display). */
67
+ export interface ComposerToken {
68
+ triggerId: string
69
+ symbol: string
70
+ id: string
71
+ label: string
72
+ display: string
73
+ metadata?: Record<string, unknown>
74
+ }
75
+
76
+ /** A token placed in the value, with its live UTF-16 range. */
77
+ export interface PlacedToken extends ComposerToken {
78
+ start: number
79
+ end: number
80
+ }
81
+
82
+ /** Structured output token with derived UTF-16 offsets into plainText. */
83
+ export interface ResolvedToken {
84
+ type: string
85
+ id: string
86
+ display: string
87
+ start: number
88
+ end: number
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Attachments
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export type ComposerAttachmentKind = 'text' | 'image' | 'video' | 'audio' | 'file'
96
+ export type ComposerAttachmentStatus = 'pending' | 'uploading' | 'ready' | 'error'
97
+
98
+ export interface ComposerAttachment {
99
+ id: string
100
+ kind: ComposerAttachmentKind
101
+ name: string
102
+ mimeType?: string
103
+ sizeBytes?: number
104
+ previewUrl?: string
105
+ status: ComposerAttachmentStatus
106
+ /** 0..1 while uploading. */
107
+ progress?: number
108
+ errorMessage?: string
109
+ metadata?: Record<string, unknown>
110
+ }
111
+
112
+ /** Input to `addAttachment` — id/status are core-owned defaults. */
113
+ export type ComposerAttachmentDraft = Omit<ComposerAttachment, 'id' | 'status'> & {
114
+ id?: string
115
+ status?: ComposerAttachmentStatus
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Submission & serialization
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export interface ComposerSubmission {
123
+ /** Trimmed message text with token displays inlined. */
124
+ plainText: string
125
+ tokens: ResolvedToken[]
126
+ attachments: ComposerAttachment[]
127
+ replyToMessageId?: string
128
+ /** Present when submitting from edit mode. */
129
+ editingMessageId?: string
130
+ }
131
+
132
+ export interface ComposerOutput {
133
+ plainText: string
134
+ tokens: ResolvedToken[]
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // State
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface ComposerSelection {
142
+ start: number
143
+ end: number
144
+ }
145
+
146
+ export interface ActiveTriggerState {
147
+ triggerId: string
148
+ symbol: string
149
+ symbolStart: number
150
+ caret: number
151
+ query: string
152
+ }
153
+
154
+ export interface SuggestionState {
155
+ isOpen: boolean
156
+ /** Full result list (overflow intact). */
157
+ items: ComposerCandidate[]
158
+ /** `items` sliced to the trigger's maxVisibleResults for the adapter. */
159
+ visibleItems: ComposerCandidate[]
160
+ activeIndex: number
161
+ loading: boolean
162
+ error: string | null
163
+ /** Monotonic id of the latest resolve request (staleness guard). */
164
+ requestToken: number
165
+ }
166
+
167
+ export interface CounterState {
168
+ /** Visible once remaining budget is within 20% of maxLength. */
169
+ visible: boolean
170
+ /** Remaining grapheme budget; null without a maxLength. */
171
+ remaining: number | null
172
+ overLimit: boolean
173
+ }
174
+
175
+ export type ComposerMode = 'compose' | 'edit'
176
+
177
+ export interface ComposerState {
178
+ value: string
179
+ selection: ComposerSelection
180
+ isComposing: boolean
181
+ isBusy: boolean
182
+ disabled: boolean
183
+ readOnly: boolean
184
+ isEmpty: boolean
185
+ canSend: boolean
186
+ error: string | null
187
+ attachments: ComposerAttachment[]
188
+ tokens: PlacedToken[]
189
+ activeTrigger: ActiveTriggerState | null
190
+ suggestion: SuggestionState
191
+ counter: CounterState
192
+ mode: ComposerMode
193
+ editingMessageId?: string
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Validation, drafts, events, config
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export interface ComposerValidationResult {
201
+ isValid: boolean
202
+ reason?: string
203
+ }
204
+
205
+ export type ComposerValidator = (
206
+ plainText: string,
207
+ tokens: ResolvedToken[],
208
+ ) => ComposerValidationResult
209
+
210
+ /** Persisted draft snapshot (attachments by id only — blobs are host-owned). */
211
+ export interface ComposerDraft {
212
+ value: string
213
+ tokens: PlacedToken[]
214
+ attachmentIds: string[]
215
+ updatedAt: number
216
+ }
217
+
218
+ /** Injected persistence seam; the core ships only an in-memory default. */
219
+ export interface ComposerDraftStore {
220
+ read(key: string): ComposerDraft | null
221
+ write(key: string, draft: ComposerDraft): void
222
+ clear(key: string): void
223
+ }
224
+
225
+ export type ComposerEvent =
226
+ | { type: 'paste-trimmed' }
227
+ | { type: 'insert-rejected'; reason: 'max-length' | 'composing' | 'disabled' | 'read-only' }
228
+ | { type: 'edit-rejected'; reason: 'inside-token' }
229
+ | { type: 'attachment-rejected'; reason: 'max-attachments' | 'max-size' | 'not-accepted'; name: string; detail?: string }
230
+ | { type: 'typing' }
231
+
232
+ export interface ComposerConfig {
233
+ initialValue?: string
234
+ /** Tokens present in initialValue (e.g. when re-opening a draft the host owns). */
235
+ initialTokens?: PlacedToken[]
236
+ /** Grapheme-cluster budget (not UTF-16 units). */
237
+ maxLength?: number
238
+ maxAttachments?: number
239
+ /** Per-attachment size gate; larger drafts are rejected with an event. */
240
+ maxAttachmentSizeBytes?: number
241
+ /** Predicate gate; return false or a reason string to reject a draft. */
242
+ acceptAttachment?: (draft: ComposerAttachmentDraft) => boolean | string
243
+ minLines?: number
244
+ maxLines?: number
245
+ triggers?: ComposerTrigger[]
246
+ validator?: ComposerValidator
247
+ draftStore?: ComposerDraftStore
248
+ draftKey?: string
249
+ /** Draft autosave debounce. Default 400ms. */
250
+ draftDebounceMs?: number
251
+ /** Leading-edge throttle for the 'typing' event. Default 3000ms. */
252
+ typingSignalIntervalMs?: number
253
+ replyToMessageId?: string
254
+ /** Injected clock — the core never calls Date.now() in logic paths. */
255
+ now?: () => number
256
+ /** Injected id factory — the core never calls Math.random(). */
257
+ generateId?: (prefix?: string) => string
258
+ /** Notice channel ('paste-trimmed', 'attachment-rejected', 'typing', …). */
259
+ onEvent?: (event: ComposerEvent) => void
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // API
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /** Outcome of a physical Enter press routed through the core. */
267
+ export type EnterResult = 'submitted' | 'newline' | 'committed-suggestion' | 'noop'
268
+
269
+ export interface ComposerAPI {
270
+ getState(): ComposerState
271
+ subscribe(listener: (state: ComposerState) => void): () => void
272
+
273
+ /**
274
+ * The adapter's input path: full new text + selection after a user edit.
275
+ * Pass `{ programmatic: true }` for host-driven writes (suppresses the
276
+ * typing signal).
277
+ */
278
+ setValue(text: string, selection?: ComposerSelection, opts?: { programmatic?: boolean }): void
279
+ /** Caret/selection move without a text change (allowed while readOnly). */
280
+ setSelection(selection: ComposerSelection): void
281
+ insertTextAtCursor(text: string): void
282
+ setComposing(isComposing: boolean): void
283
+
284
+ /** Routes a physical Enter; the return value tells the adapter whether to preventDefault. */
285
+ applyEnter(opts: { shiftPressed: boolean }): EnterResult
286
+
287
+ moveSuggestionNext(): void
288
+ moveSuggestionPrevious(): void
289
+ /** Pointer hover — last-input-wins with the keyboard. */
290
+ setSuggestionActiveIndex(index: number): void
291
+ applySuggestion(index?: number): void
292
+ /** Escape-style dismissal: closes now and marks the occurrence dismissed. */
293
+ dismissSuggestion(): void
294
+ /** Blur-style dismissal: closes after a short grace so a click can land. */
295
+ dismissSuggestionDeferred(): void
296
+ retrySuggestions(): void
297
+
298
+ addAttachment(draft: ComposerAttachmentDraft): string | null
299
+ updateAttachment(id: string, patch: Partial<Omit<ComposerAttachment, 'id'>>): void
300
+ removeAttachment(id: string): void
301
+
302
+ setBusy(busy: boolean): void
303
+ setError(error: string | null): void
304
+ setDisabled(disabled: boolean): void
305
+ setReadOnly(readOnly: boolean): void
306
+
307
+ beginEdit(args: { value: string; tokens?: PlacedToken[]; messageId: string }): void
308
+ cancelEdit(): void
309
+
310
+ /** Clipboard seam: returns the selected display text. */
311
+ copySelection(): string
312
+ /** Clipboard seam: removes the selection, retaining token identity for same-instance paste. */
313
+ cutSelection(): string
314
+ /** Clipboard seam: plain-text insert at caret (clamped); restores tokens only for a same-instance cut. */
315
+ pasteText(text: string): void
316
+
317
+ submit(): ComposerSubmission | null
318
+ reset(): void
319
+ serialize(): ComposerOutput
320
+ destroy(): void
321
+ }
322
+
323
+ /**
324
+ * Bridge to the conversation layer: a ready composer attachment mapped onto
325
+ * `@refraction-ui/conversation`'s wire shape.
326
+ */
327
+ export function toMessageAttachment(attachment: ComposerAttachment): MessageAttachment {
328
+ return {
329
+ id: attachment.id,
330
+ name: attachment.name,
331
+ url: attachment.previewUrl ?? '',
332
+ type: attachment.mimeType ?? attachment.kind,
333
+ size: attachment.sizeBytes,
334
+ }
335
+ }
336
+
337
+ export type { MessageAttachment }
package/dist/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './astro-code-editor/index.ts';
30
30
  export * from './astro-collapsible/index.ts';
31
31
  export * from './astro-command/index.ts';
32
32
  export * from './astro-command-input/index.ts';
33
+ export * from './astro-composer/index.ts';
33
34
  export * from './astro-content-protection/index.ts';
34
35
  export * from './astro-conversation/index.ts';
35
36
  export * from './astro-cookie-consent/index.ts';
@@ -83,6 +83,10 @@ export {
83
83
  // Markdown shortcuts
84
84
  export { processMarkdownShortcut } from './markdown-shortcuts.js'
85
85
 
86
+ // Trigger detection (generalized — mentions/slash delegate to this)
87
+ export type { TriggerHit } from './trigger.js'
88
+ export { detectTriggerInText } from './trigger.js'
89
+
86
90
  // Slash commands
87
91
  export type { SlashCommand, SlashCommandMenu } from './slash-commands.js'
88
92
  export { BUILT_IN_COMMANDS, createSlashCommandMenu, detectSlashTrigger } from './slash-commands.js'
@@ -6,6 +6,7 @@ import type { Document } from './model.js'
6
6
  import type { Selection } from './selection.js'
7
7
  import type { MentionSegment } from './model.js'
8
8
  import { findBlockById, getBlockText, createMentionSegment } from './model.js'
9
+ import { detectTriggerInText } from './trigger.js'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Types
@@ -142,21 +143,6 @@ export function detectMentionTrigger(
142
143
  const block = findBlockById(doc, sel.anchor.blockId)
143
144
  if (!block) return { triggered: false, query: '' }
144
145
 
145
- const text = getBlockText(block)
146
- const textBeforeCursor = text.slice(0, sel.anchor.offset)
147
-
148
- // Look for "@" preceded by start-of-text or a space
149
- const atIdx = textBeforeCursor.lastIndexOf('@')
150
- if (atIdx === -1) return { triggered: false, query: '' }
151
-
152
- // The @ must be at position 0 or preceded by a space
153
- if (atIdx > 0 && textBeforeCursor[atIdx - 1] !== ' ') {
154
- return { triggered: false, query: '' }
155
- }
156
-
157
- const query = textBeforeCursor.slice(atIdx + 1)
158
- // If query contains spaces, the mention trigger is no longer active
159
- if (query.includes(' ')) return { triggered: false, query: '' }
160
-
161
- return { triggered: true, query }
146
+ const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '@')
147
+ return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }
162
148
  }
@@ -10,6 +10,7 @@ import type { EditorState } from './operations.js'
10
10
  import { changeBlockType, insertBlock, deleteSelection } from './operations.js'
11
11
  import { createCollapsedSelection, createSelection, createPosition } from './selection.js'
12
12
  import { findBlockById, getBlockText, cloneDocument, createTextSegment } from './model.js'
13
+ import { detectTriggerInText } from './trigger.js'
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Types
@@ -192,21 +193,6 @@ export function detectSlashTrigger(
192
193
  const block = findBlockById(doc, sel.anchor.blockId)
193
194
  if (!block) return { triggered: false, query: '' }
194
195
 
195
- const text = getBlockText(block)
196
- const textBeforeCursor = text.slice(0, sel.anchor.offset)
197
-
198
- // Look for "/" preceded by start-of-text or a space
199
- const slashIdx = textBeforeCursor.lastIndexOf('/')
200
- if (slashIdx === -1) return { triggered: false, query: '' }
201
-
202
- // The slash must be at position 0 or preceded by a space
203
- if (slashIdx > 0 && textBeforeCursor[slashIdx - 1] !== ' ') {
204
- return { triggered: false, query: '' }
205
- }
206
-
207
- const query = textBeforeCursor.slice(slashIdx + 1)
208
- // If query contains spaces, the slash command is no longer active
209
- if (query.includes(' ')) return { triggered: false, query: '' }
210
-
211
- return { triggered: true, query }
196
+ const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '/')
197
+ return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }
212
198
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Generalized trigger detection shared by mentions, slash commands, and any
3
+ * symbol-prefixed autocomplete (e.g. the composer's trigger engine).
4
+ *
5
+ * One implementation exists so the boundary rules ("alice@example.com" never
6
+ * triggers, a query never spans whitespace) cannot drift between features.
7
+ */
8
+
9
+ export interface TriggerHit {
10
+ triggered: boolean
11
+ query: string
12
+ /** Index of the trigger symbol's first character in `text`. */
13
+ start: number
14
+ }
15
+
16
+ /**
17
+ * Scan backward from `caret` for the nearest occurrence of `triggerChar` that
18
+ * is preceded by start-of-text or whitespace and followed by a whitespace-free
19
+ * query running up to the caret. Returns `null` when no live trigger exists.
20
+ *
21
+ * `triggerChar` may be longer than one character (e.g. `!!`); the boundary is
22
+ * checked against the character before its first character.
23
+ */
24
+ export function detectTriggerInText(
25
+ text: string,
26
+ caret: number,
27
+ triggerChar: string,
28
+ ): TriggerHit | null {
29
+ if (triggerChar.length === 0) return null
30
+ const before = text.slice(0, caret)
31
+ const idx = before.lastIndexOf(triggerChar)
32
+ if (idx === -1) return null
33
+ // Boundary rule: only start-of-text or whitespace may precede the symbol,
34
+ // so mid-word occurrences (emails, "and/or") never arm.
35
+ if (idx > 0 && !/\s/.test(before[idx - 1])) return null
36
+ const query = before.slice(idx + triggerChar.length)
37
+ // A query never spans whitespace — a space closes the trigger.
38
+ if (/\s/.test(query)) return null
39
+ return { triggered: true, query, start: idx }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refraction-ui/astro",
3
- "version": "0.15.2",
3
+ "version": "0.16.1",
4
4
  "description": "All Refraction UI Astro components in one package",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,6 +50,7 @@
50
50
  "@refraction-ui/astro-collapsible": "workspace:*",
51
51
  "@refraction-ui/astro-command": "workspace:*",
52
52
  "@refraction-ui/astro-command-input": "workspace:*",
53
+ "@refraction-ui/astro-composer": "workspace:*",
53
54
  "@refraction-ui/astro-content-protection": "workspace:*",
54
55
  "@refraction-ui/astro-conversation": "workspace:*",
55
56
  "@refraction-ui/astro-cookie-consent": "workspace:*",