@joewinke/jatui 0.34.2 → 0.36.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.
Files changed (41) hide show
  1. package/CLAUDE.md +9 -3
  2. package/dist/actions/reveal.d.ts +72 -11
  3. package/dist/actions/reveal.js +103 -14
  4. package/dist/components/AudioRecorder.svelte +23 -1
  5. package/dist/components/AudioRecorder.svelte.d.ts +13 -1
  6. package/dist/components/BulkActionBar.svelte +17 -2
  7. package/dist/components/BulkActionBar.svelte.d.ts +5 -0
  8. package/dist/components/Drawer.svelte +20 -1
  9. package/dist/components/Drawer.svelte.d.ts +11 -0
  10. package/dist/components/FloatingActionBar.svelte +26 -11
  11. package/dist/components/FloatingActionBar.svelte.d.ts +5 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte +180 -7
  13. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +21 -0
  14. package/dist/components/ListenControl.svelte +1064 -0
  15. package/dist/components/ListenControl.svelte.d.ts +87 -0
  16. package/dist/components/ThumbnailGrid.svelte +34 -7
  17. package/dist/components/ThumbnailGrid.svelte.d.ts +17 -1
  18. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  19. package/dist/index.d.ts +9 -3
  20. package/dist/index.js +9 -2
  21. package/dist/listen/keys.d.ts +36 -0
  22. package/dist/listen/keys.js +68 -0
  23. package/dist/listen/mediaSession.d.ts +90 -0
  24. package/dist/listen/mediaSession.js +108 -0
  25. package/dist/listen/prefs.d.ts +49 -0
  26. package/dist/listen/prefs.js +97 -0
  27. package/dist/listen/segments.d.ts +101 -0
  28. package/dist/listen/segments.js +224 -0
  29. package/dist/listen/types.d.ts +81 -0
  30. package/dist/listen/types.js +7 -0
  31. package/dist/motion/index.d.ts +1 -1
  32. package/dist/motion/index.js +1 -1
  33. package/dist/utils/audioRecorder.d.ts +6 -1
  34. package/dist/utils/audioRecorder.js +5 -0
  35. package/dist/utils/audioRecorderCore.d.ts +34 -0
  36. package/dist/utils/audioRecorderCore.js +43 -6
  37. package/dist/utils/keyboardShortcutHub.d.ts +73 -0
  38. package/dist/utils/keyboardShortcutHub.js +89 -0
  39. package/dist/utils/mentionParser.d.ts +44 -2
  40. package/dist/utils/mentionParser.js +84 -4
  41. package/package.json +2 -1
@@ -57,11 +57,32 @@ export interface AudioRecorderFrame {
57
57
  /** Time-domain bytes (from getByteTimeDomainData) — raw PCM-ish shape. */
58
58
  time: Uint8Array;
59
59
  }
60
+ /** One MediaRecorder timeslice of audio, handed to `onChunk` as it arrives
61
+ * (jatui-ft2ot). The take keeps accumulating for `onStop` regardless — hosts
62
+ * that buffer chunks (restartable dictation, live transcription) consume
63
+ * both streams independently. */
64
+ export interface AudioRecorderChunk {
65
+ /** The chunk blob — one MediaRecorder `dataavailable` payload. */
66
+ blob: Blob;
67
+ /** MIME type of the recording (same value `onStop` reports). */
68
+ mimeType: string;
69
+ /** Cumulative bytes captured so far in this take (all chunks, not just this one). */
70
+ sizeBytes: number;
71
+ /** Pause-adjusted elapsed ms at the moment this chunk arrived. */
72
+ elapsedMs: number;
73
+ /** 0-based index of this chunk within the take. Resets on a new take. */
74
+ index: number;
75
+ }
60
76
  export interface AudioRecorderOptions {
61
77
  /** `'toggle'` (default) or `'push'` (hold-to-talk — see module header). */
62
78
  mode?: AudioRecorderMode;
63
79
  /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
64
80
  maxDurationMs?: number;
81
+ /** Timeslice in ms passed to `MediaRecorder.start()` — chunks are emitted
82
+ * at least this often and delivered to `onChunk` (jatui-ft2ot). Default
83
+ * 1000 (the historical behavior). 0 records as a single blob until stop
84
+ * (no intermediate chunking). */
85
+ timesliceMs?: number;
65
86
  /** Fired once the mic stream is live and recording has started. */
66
87
  onStart?: () => void;
67
88
  onPause?: () => void;
@@ -77,6 +98,12 @@ export interface AudioRecorderOptions {
77
98
  /** Fired every animation frame while recording — analyser data for hosts
78
99
  * that draw their own waveform/level UI. Not fired while paused. */
79
100
  onFrame?: (frame: AudioRecorderFrame) => void;
101
+ /** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
102
+ * a final shorter chunk at stop). The take still accumulates for `onStop` —
103
+ * hosts get the full take AND a live chunk stream (restartable dictation,
104
+ * streaming transcription). A throwing handler reports `handler-failed`
105
+ * but does NOT stop the recording. */
106
+ onChunk?: (chunk: AudioRecorderChunk) => void;
80
107
  }
81
108
  /** Injectable platform bindings — defaults resolve from globalThis lazily at factory time, so Node (no MediaRecorder) degrades to `supported: false` instead of throwing at import. */
82
109
  export interface AudioRecorderDeps {
@@ -104,11 +131,18 @@ export interface AudioRecorderSnapshot {
104
131
  elapsedMs: number;
105
132
  /** Last error, or null. */
106
133
  error: AudioRecorderError | null;
134
+ /** Live mic MediaStream while the recorder holds it (starting/recording),
135
+ * else null (jatui-ft2ot). For hosts that attach the raw stream — WebRTC,
136
+ * a custom MediaRecorder with requestData()-driven streaming. */
137
+ stream: MediaStream | null;
107
138
  }
108
139
  export interface AudioRecorderCore {
109
140
  /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
110
141
  readonly supported: boolean;
111
142
  readonly mode: AudioRecorderMode;
143
+ /** Live mic MediaStream while the recorder holds it, else null — same
144
+ * value as `snapshot().stream`, for hosts that read it imperatively. */
145
+ readonly stream: MediaStream | null;
112
146
  /** Current reactive-independent snapshot (the rune wrapper copies this into $state). */
113
147
  snapshot(): AudioRecorderSnapshot;
114
148
  /** Subscribe to snapshot changes. Returns an unsubscribe fn. */
@@ -61,7 +61,7 @@ export function computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now)
61
61
  return Math.max(0, now - startedAt - pausedMs);
62
62
  }
63
63
  export function createAudioRecorderCore(options = {}, deps = {}) {
64
- const { mode = 'toggle', maxDurationMs = 0, onStart, onPause, onResume, onStop, onCancel, onError, onFrame } = options;
64
+ const { mode = 'toggle', maxDurationMs = 0, timesliceMs = 1000, onStart, onPause, onResume, onStop, onCancel, onError, onFrame, onChunk } = options;
65
65
  // Lazy globalThis defaults — resolved per factory call, never at import time.
66
66
  const g = (v) => v;
67
67
  const getUserMedia = deps.getUserMedia ??
@@ -87,6 +87,8 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
87
87
  let level = 0;
88
88
  let sizeBytes = 0;
89
89
  let error = null;
90
+ /** 0-based index of the next chunk in the current take. */
91
+ let chunkIndex = 0;
90
92
  // Recorder internals
91
93
  let recorder = null;
92
94
  let stream = null;
@@ -105,7 +107,15 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
105
107
  let pendingCancel = false;
106
108
  const listeners = new Set();
107
109
  function snapshot() {
108
- return { phase, paused, level, sizeBytes, elapsedMs: computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now()), error };
110
+ return {
111
+ phase,
112
+ paused,
113
+ level,
114
+ sizeBytes,
115
+ elapsedMs: computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now()),
116
+ error,
117
+ stream
118
+ };
109
119
  }
110
120
  function notify() {
111
121
  const s = snapshot();
@@ -273,11 +283,32 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
273
283
  }
274
284
  chunks = [];
275
285
  sizeBytes = 0;
286
+ chunkIndex = 0;
276
287
  recorder.ondataavailable = (e) => {
277
288
  if (e.data && e.data.size > 0) {
278
- chunks.push(e.data);
279
- sizeBytes += e.data.size;
289
+ const blob = e.data;
290
+ chunks.push(blob);
291
+ sizeBytes += blob.size;
292
+ const chunk = {
293
+ blob,
294
+ mimeType,
295
+ sizeBytes,
296
+ elapsedMs: elapsed(),
297
+ index: chunkIndex++
298
+ };
280
299
  notify();
300
+ if (onChunk) {
301
+ try {
302
+ onChunk(chunk);
303
+ }
304
+ catch (err) {
305
+ // A throwing chunk handler must not lose the take — report and keep capturing.
306
+ fail({
307
+ kind: 'handler-failed',
308
+ message: `Chunk kept, but the onChunk handler failed: ${err instanceof Error ? err.message : String(err)}`
309
+ });
310
+ }
311
+ }
281
312
  }
282
313
  };
283
314
  recorder.onstop = () => {
@@ -300,7 +331,7 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
300
331
  analyser = null;
301
332
  }
302
333
  }
303
- recorder.start(1000);
334
+ recorder.start(timesliceMs > 0 ? timesliceMs : undefined);
304
335
  startedAt = now();
305
336
  pausedTotalMs = 0;
306
337
  pauseStartedAt = 0;
@@ -381,7 +412,10 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
381
412
  if (wasCancelled)
382
413
  return;
383
414
  if (chunks.length === 0) {
384
- // Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
415
+ // Zero chunks (e.g. stopped before any data arrived) — fail loud, not a
416
+ // silent no-op. Release the mic + audio graph like every other exit, so
417
+ // a failed take can't leave a hot microphone behind.
418
+ teardown();
385
419
  phase = 'idle';
386
420
  notify();
387
421
  fail({ kind: 'too-short', message: 'Recording was too short — nothing was captured. Try again.' });
@@ -434,6 +468,9 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
434
468
  return supported;
435
469
  },
436
470
  mode,
471
+ get stream() {
472
+ return stream;
473
+ },
437
474
  snapshot,
438
475
  subscribe,
439
476
  start,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Keyboard shortcut hub — the "one overlay" coordination layer for
3
+ * KeyboardShortcutsOverlay's host/contributor mode.
4
+ *
5
+ * Page-level overlay instances usually self-render (the pre-existing
6
+ * non-host fallback path). But an app shell can mount ONE "global" host
7
+ * overlay; every other overlay on the page then becomes a contributor:
8
+ * instead of self-rendering it contributes its sections (under a
9
+ * `sectionTitle`) to the host's panel and forwards its `open` requests to
10
+ * the host, so exactly one `?`-overlay exists no matter how many surfaces
11
+ * mount the component.
12
+ *
13
+ * Pure TypeScript (no Svelte), subscriber-based like ShortcutRegistry —
14
+ * reactivity lives in the overlay components, which re-read on notify.
15
+ *
16
+ * Usage (app shell):
17
+ *
18
+ * <KeyboardShortcutsOverlay mode="host" /> // owns the `?` toggle
19
+ *
20
+ * Usage (page / portal / contract page):
21
+ *
22
+ * <KeyboardShortcutsOverlay
23
+ * {sections}
24
+ * sectionTitle="Inbox"
25
+ * showHint={false}
26
+ * />
27
+ * // self-renders when no host exists; contributes when one does
28
+ */
29
+ export interface ShortcutContribution {
30
+ /** Stable id for the contributing surface (page route, portal name…). */
31
+ id: string;
32
+ /** Label under which the contributed sections appear in the host panel. */
33
+ sectionTitle: string;
34
+ /** Contributed sections (same shape as KeyboardShortcutsOverlay's `sections`). */
35
+ sections: ShortcutSectionLike[];
36
+ /** Registry snapshot (context-keyed sections) — optional alternative to `sections`. */
37
+ registrySections?: ShortcutSectionLike[];
38
+ }
39
+ export interface ShortcutSectionLike {
40
+ title: string;
41
+ shortcuts: {
42
+ key: string;
43
+ description: string;
44
+ color?: string;
45
+ }[];
46
+ }
47
+ export interface KeyboardShortcutHost {
48
+ /** Toggle the host overlay open/closed. */
49
+ toggle: () => void;
50
+ /** Open the host overlay (idempotent while already open). */
51
+ open: () => void;
52
+ }
53
+ declare class KeyboardShortcutHub {
54
+ private host;
55
+ private contributions;
56
+ private listeners;
57
+ /** True when a host overlay is mounted (contributing instances defer to it). */
58
+ get hasHost(): boolean;
59
+ /** Mount the host. Returns the unmount function. */
60
+ registerHost(host: KeyboardShortcutHost): () => void;
61
+ /** Contribute sections from a page-level overlay instance. Returns unregister. */
62
+ contribute(contribution: ShortcutContribution): () => void;
63
+ /** Contributed sections in registration order, labelled by sectionTitle. */
64
+ getContributions(): ShortcutContribution[];
65
+ /** Ask the host overlay to open. No-op when there is no host. */
66
+ requestOpen(): boolean;
67
+ /** Subscribe to hub changes (host mount/unmount, contributions). */
68
+ subscribe(listener: () => void): () => void;
69
+ private notify;
70
+ }
71
+ export declare const keyboardShortcutHub: KeyboardShortcutHub;
72
+ export declare const KEYBOARD_SHORTCUT_HUB_ID = "keyboard-shortcut-hub";
73
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Keyboard shortcut hub — the "one overlay" coordination layer for
3
+ * KeyboardShortcutsOverlay's host/contributor mode.
4
+ *
5
+ * Page-level overlay instances usually self-render (the pre-existing
6
+ * non-host fallback path). But an app shell can mount ONE "global" host
7
+ * overlay; every other overlay on the page then becomes a contributor:
8
+ * instead of self-rendering it contributes its sections (under a
9
+ * `sectionTitle`) to the host's panel and forwards its `open` requests to
10
+ * the host, so exactly one `?`-overlay exists no matter how many surfaces
11
+ * mount the component.
12
+ *
13
+ * Pure TypeScript (no Svelte), subscriber-based like ShortcutRegistry —
14
+ * reactivity lives in the overlay components, which re-read on notify.
15
+ *
16
+ * Usage (app shell):
17
+ *
18
+ * <KeyboardShortcutsOverlay mode="host" /> // owns the `?` toggle
19
+ *
20
+ * Usage (page / portal / contract page):
21
+ *
22
+ * <KeyboardShortcutsOverlay
23
+ * {sections}
24
+ * sectionTitle="Inbox"
25
+ * showHint={false}
26
+ * />
27
+ * // self-renders when no host exists; contributes when one does
28
+ */
29
+ const HUB_ID = 'keyboard-shortcut-hub';
30
+ class KeyboardShortcutHub {
31
+ host = null;
32
+ contributions = new Map();
33
+ listeners = new Set();
34
+ /** True when a host overlay is mounted (contributing instances defer to it). */
35
+ get hasHost() {
36
+ return this.host !== null;
37
+ }
38
+ /** Mount the host. Returns the unmount function. */
39
+ registerHost(host) {
40
+ if (this.host && this.host !== host) {
41
+ // Two hosts — the later one wins (last-mounted). Warn loudly: a
42
+ // page accidentally mounting a second global overlay is a bug.
43
+ console.warn('[keyboardShortcutHub] replacing an existing host overlay — did you mount the global overlay twice?');
44
+ }
45
+ this.host = host;
46
+ this.notify();
47
+ return () => {
48
+ if (this.host === host) {
49
+ this.host = null;
50
+ this.notify();
51
+ }
52
+ };
53
+ }
54
+ /** Contribute sections from a page-level overlay instance. Returns unregister. */
55
+ contribute(contribution) {
56
+ this.contributions.set(contribution.id, contribution);
57
+ this.notify();
58
+ return () => {
59
+ if (this.contributions.get(contribution.id) === contribution) {
60
+ this.contributions.delete(contribution.id);
61
+ this.notify();
62
+ }
63
+ };
64
+ }
65
+ /** Contributed sections in registration order, labelled by sectionTitle. */
66
+ getContributions() {
67
+ return [...this.contributions.values()];
68
+ }
69
+ /** Ask the host overlay to open. No-op when there is no host. */
70
+ requestOpen() {
71
+ if (!this.host)
72
+ return false;
73
+ this.host.open();
74
+ return true;
75
+ }
76
+ /** Subscribe to hub changes (host mount/unmount, contributions). */
77
+ subscribe(listener) {
78
+ this.listeners.add(listener);
79
+ return () => {
80
+ this.listeners.delete(listener);
81
+ };
82
+ }
83
+ notify() {
84
+ for (const l of this.listeners)
85
+ l();
86
+ }
87
+ }
88
+ export const keyboardShortcutHub = new KeyboardShortcutHub();
89
+ export const KEYBOARD_SHORTCUT_HUB_ID = HUB_ID;
@@ -4,8 +4,16 @@
4
4
  * Handles parsing and rendering of @-mentions in messages.
5
5
  * Format: [@Display Name](type:id) -> clickable links
6
6
  *
7
- * The URL generator is configurable — consuming apps provide their own
8
- * routing via the `urlGenerator` parameter or by setting a default.
7
+ * URL routing has three layers (most specific first):
8
+ * 1. the `urlGenerator` parameter on a given parse/render call
9
+ * 2. a per-type registration — registerMentionType(type, generator) —
10
+ * so each mention type carries its own routing (e.g. @agent mentions
11
+ * route to /admin/settings/agent while @user routes elsewhere)
12
+ * 3. the single app-wide default — setMentionUrlGenerator
13
+ *
14
+ * The per-type registry is what lets a consuming app retire a local
15
+ * mentionParser copy (mos-i8nnd.18): one default generator cannot express
16
+ * per-type routing.
9
17
  */
10
18
  export interface MentionMatch {
11
19
  type: string;
@@ -17,6 +25,22 @@ export interface MentionMatch {
17
25
  fullMatch: string;
18
26
  }
19
27
  export type MentionUrlGenerator = (type: string, id: string) => string;
28
+ /** The mention type used to reference agents (extractAgentMentions). */
29
+ export declare const AGENT_MENTION_TYPE = "agent";
30
+ /** Per-type registration record held by the registry. */
31
+ export interface MentionTypeConfig {
32
+ urlGenerator: MentionUrlGenerator;
33
+ }
34
+ /**
35
+ * Register a URL generator for a specific mention type.
36
+ *
37
+ * Call once at app startup per routed type (e.g. registerMentionType('agent',
38
+ * () => '/admin/settings/agent')). Re-registering a type replaces its
39
+ * generator; the returned function unregisters exactly that registration.
40
+ * The type must be an identifier token — the mention regex only accepts
41
+ * `[A-Za-z0-9_-]+`, so anything else could never match a mention.
42
+ */
43
+ export declare function registerMentionType(type: string, urlGenerator: MentionUrlGenerator): () => void;
20
44
  /**
21
45
  * Set the default URL generator for mentions.
22
46
  * Call this once at app startup to configure mention link routing.
@@ -73,6 +97,24 @@ export declare function replaceMentionInContent(content: string, startIndex: num
73
97
  newContent: string;
74
98
  newCursorPosition: number;
75
99
  };
100
+ /**
101
+ * Extract only agent mentions (type='agent').
102
+ */
103
+ export declare function extractAgentMentions(content: string): MentionMatch[];
104
+ /**
105
+ * Cheap boolean check — does the content contain at least one mention?
106
+ */
107
+ export declare function hasMentions(content: string): boolean;
108
+ /**
109
+ * Unique ids of @user mentions, in order of first appearance.
110
+ * Agent mentions are separate — use extractAgentMentions.
111
+ */
112
+ export declare function getMentionedUserIds(content: string): string[];
113
+ /**
114
+ * Mentions deduped by type+id (first occurrence wins, appearance order
115
+ * preserved), optionally filtered to a single type.
116
+ */
117
+ export declare function getUniqueMentionsByType(content: string, type?: string): MentionMatch[];
76
118
  /**
77
119
  * Simple HTML escape function
78
120
  */
@@ -4,9 +4,43 @@
4
4
  * Handles parsing and rendering of @-mentions in messages.
5
5
  * Format: [@Display Name](type:id) -> clickable links
6
6
  *
7
- * The URL generator is configurable — consuming apps provide their own
8
- * routing via the `urlGenerator` parameter or by setting a default.
7
+ * URL routing has three layers (most specific first):
8
+ * 1. the `urlGenerator` parameter on a given parse/render call
9
+ * 2. a per-type registration — registerMentionType(type, generator) —
10
+ * so each mention type carries its own routing (e.g. @agent mentions
11
+ * route to /admin/settings/agent while @user routes elsewhere)
12
+ * 3. the single app-wide default — setMentionUrlGenerator
13
+ *
14
+ * The per-type registry is what lets a consuming app retire a local
15
+ * mentionParser copy (mos-i8nnd.18): one default generator cannot express
16
+ * per-type routing.
17
+ */
18
+ /** The mention type used to reference agents (extractAgentMentions). */
19
+ export const AGENT_MENTION_TYPE = 'agent';
20
+ // Per-type registry — populated by registerMentionType; wins over the
21
+ // app-wide default for that type, loses to an explicit generator argument.
22
+ const mentionTypes = new Map();
23
+ /**
24
+ * Register a URL generator for a specific mention type.
25
+ *
26
+ * Call once at app startup per routed type (e.g. registerMentionType('agent',
27
+ * () => '/admin/settings/agent')). Re-registering a type replaces its
28
+ * generator; the returned function unregisters exactly that registration.
29
+ * The type must be an identifier token — the mention regex only accepts
30
+ * `[A-Za-z0-9_-]+`, so anything else could never match a mention.
9
31
  */
32
+ export function registerMentionType(type, urlGenerator) {
33
+ if (!/^[A-Za-z0-9_-]+$/.test(type)) {
34
+ throw new TypeError(`mention type ${JSON.stringify(type)} must match /^[A-Za-z0-9_-]+$/ — ` +
35
+ 'the mention regex only matches identifier tokens');
36
+ }
37
+ mentionTypes.set(type, { urlGenerator });
38
+ return () => {
39
+ // Only remove OUR registration — a later re-registration must survive.
40
+ if (mentionTypes.get(type)?.urlGenerator === urlGenerator)
41
+ mentionTypes.delete(type);
42
+ };
43
+ }
10
44
  // Regex to match mention pattern: [@Display Name](type:id)
11
45
  //
12
46
  // `type` is an identifier token, never free text (mos-3xvn1): it lands in
@@ -28,7 +62,6 @@ export function setMentionUrlGenerator(generator) {
28
62
  */
29
63
  export function parseMentions(content, urlGenerator) {
30
64
  const mentions = [];
31
- const gen = urlGenerator ?? defaultUrlGenerator;
32
65
  let match;
33
66
  MENTION_REGEX.lastIndex = 0;
34
67
  while ((match = MENTION_REGEX.exec(content)) !== null) {
@@ -37,7 +70,9 @@ export function parseMentions(content, urlGenerator) {
37
70
  type,
38
71
  id,
39
72
  displayName,
40
- url: gen(type, id),
73
+ // Resolution: explicit call-site generator > per-type registered
74
+ // generator > the app-wide default.
75
+ url: (urlGenerator ?? mentionTypes.get(type)?.urlGenerator ?? defaultUrlGenerator)(type, id),
41
76
  startIndex: match.index,
42
77
  endIndex: match.index + fullMatch.length,
43
78
  fullMatch
@@ -147,6 +182,51 @@ export function replaceMentionInContent(content, startIndex, endIndex, mentionSt
147
182
  const newCursorPosition = startIndex + mentionString.length;
148
183
  return { newContent, newCursorPosition };
149
184
  }
185
+ /**
186
+ * Extract only agent mentions (type='agent').
187
+ */
188
+ export function extractAgentMentions(content) {
189
+ return parseMentions(content).filter((mention) => mention.type === AGENT_MENTION_TYPE);
190
+ }
191
+ /**
192
+ * Cheap boolean check — does the content contain at least one mention?
193
+ */
194
+ export function hasMentions(content) {
195
+ MENTION_REGEX.lastIndex = 0;
196
+ return MENTION_REGEX.test(content);
197
+ }
198
+ /**
199
+ * Unique ids of @user mentions, in order of first appearance.
200
+ * Agent mentions are separate — use extractAgentMentions.
201
+ */
202
+ export function getMentionedUserIds(content) {
203
+ const ids = [];
204
+ for (const mention of parseMentions(content)) {
205
+ if (mention.type !== 'user')
206
+ continue;
207
+ if (!ids.includes(mention.id))
208
+ ids.push(mention.id);
209
+ }
210
+ return ids;
211
+ }
212
+ /**
213
+ * Mentions deduped by type+id (first occurrence wins, appearance order
214
+ * preserved), optionally filtered to a single type.
215
+ */
216
+ export function getUniqueMentionsByType(content, type) {
217
+ const seen = new Set();
218
+ const out = [];
219
+ for (const mention of parseMentions(content)) {
220
+ if (type && mention.type !== type)
221
+ continue;
222
+ const key = `${mention.type}:${mention.id}`;
223
+ if (seen.has(key))
224
+ continue;
225
+ seen.add(key);
226
+ out.push(mention);
227
+ }
228
+ return out;
229
+ }
150
230
  /**
151
231
  * Simple HTML escape function
152
232
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.34.2",
3
+ "version": "0.36.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -188,6 +188,7 @@
188
188
  "lenis": "^1.3.25",
189
189
  "livekit-client": "^2.20.1",
190
190
  "motion": "^12.42.2",
191
+ "@playwright/test": "^1.62.1",
191
192
  "playwright": "^1.62.1",
192
193
  "svelte": "^5.0.0",
193
194
  "tailwindcss": "^4.0.0",