@joewinke/jatui 0.38.5 → 0.39.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 (67) hide show
  1. package/CLAUDE.md +32 -1
  2. package/THIRD_PARTY_NOTICES.md +51 -0
  3. package/dist/components/BulkActionBar.svelte +32 -2
  4. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  5. package/dist/components/FloatingActionBar.svelte +102 -34
  6. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  7. package/dist/components/MediaExplorer.svelte +249 -0
  8. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  9. package/dist/components/NotificationCenter.svelte +481 -0
  10. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  11. package/dist/components/SearchDropdown.svelte +54 -8
  12. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  13. package/dist/components/VideoPlayer.svelte +269 -3
  14. package/dist/components/agent/AgentLog.svelte +217 -0
  15. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  16. package/dist/components/agent/AgentLogRow.svelte +281 -0
  17. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  18. package/dist/components/agent/AgentTaskList.svelte +607 -0
  19. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  20. package/dist/components/agent/AgentThinking.svelte +280 -0
  21. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  22. package/dist/components/agent/ComposerGlow.svelte +265 -0
  23. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  24. package/dist/components/agent/ContextMeter.svelte +277 -0
  25. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  26. package/dist/components/agent/Questionnaire.svelte +767 -0
  27. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  28. package/dist/components/agent/ShimmerText.svelte +100 -0
  29. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  30. package/dist/components/agent/StreamedText.svelte +135 -0
  31. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  32. package/dist/components/agent/agentLog.d.ts +22 -0
  33. package/dist/components/agent/agentLog.js +26 -0
  34. package/dist/components/agent/agentTaskList.d.ts +90 -0
  35. package/dist/components/agent/agentTaskList.js +93 -0
  36. package/dist/components/agent/agentThinking.d.ts +46 -0
  37. package/dist/components/agent/agentThinking.js +78 -0
  38. package/dist/components/agent/contextMeter.d.ts +97 -0
  39. package/dist/components/agent/contextMeter.js +156 -0
  40. package/dist/components/agent/questionnaire.d.ts +104 -0
  41. package/dist/components/agent/questionnaire.js +225 -0
  42. package/dist/components/agent/revealCore.d.ts +74 -0
  43. package/dist/components/agent/revealCore.js +160 -0
  44. package/dist/components/agent/revealTicker.d.ts +34 -0
  45. package/dist/components/agent/revealTicker.js +73 -0
  46. package/dist/components/agent/streamedText.d.ts +28 -0
  47. package/dist/components/agent/streamedText.js +59 -0
  48. package/dist/components/messaging/MessageInput.svelte +34 -4
  49. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  50. package/dist/components/messaging/MessageItem.svelte +2 -2
  51. package/dist/components/notificationCenter.d.ts +65 -0
  52. package/dist/components/notificationCenter.js +110 -0
  53. package/dist/components/progress/Steps.svelte +269 -5
  54. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  55. package/dist/components/progress/stepsSummary.d.ts +14 -0
  56. package/dist/components/progress/stepsSummary.js +32 -0
  57. package/dist/index.d.ts +34 -0
  58. package/dist/index.js +29 -0
  59. package/dist/selection/clickSelection.d.ts +89 -0
  60. package/dist/selection/clickSelection.js +152 -0
  61. package/dist/selection/createClickSelection.d.ts +79 -0
  62. package/dist/selection/createClickSelection.js +134 -0
  63. package/dist/video/keys.d.ts +5 -5
  64. package/dist/video/keys.js +9 -5
  65. package/dist/video/transcriptSearch.d.ts +80 -0
  66. package/dist/video/transcriptSearch.js +153 -0
  67. package/package.json +3 -2
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Reveal ticker — the pacing core behind AgentLog / AgentTaskList.
3
+ *
4
+ * Counts how many units of a streaming log are visible. Two modes:
5
+ *
6
+ * timer one unit per tick: `startDelay` before the first, then
7
+ * `stepInterval` between the rest, or `delayFor(i)` per unit
8
+ * (the dwell before unit `i` appears) when given.
9
+ * controlled pass `revealed` and the timer is off: real events drive
10
+ * the count, clamped to [0, total].
11
+ *
12
+ * `onComplete` fires exactly once each time the count reaches `total`
13
+ * (total > 0). Dropping back below total (total grew, `replay()`, a lower
14
+ * controlled value) re-arms it.
15
+ *
16
+ * Framework-free and DOM-free, with an injectable clock, so
17
+ * tests/unit/revealTicker.test.ts drives it with fake timers. The Svelte
18
+ * rune wrapper is createRevealTicker in revealTicker.svelte.ts.
19
+ *
20
+ * Behaviour adapted from BoardUI's agent-log `useRevealTicker`:
21
+ * MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)
22
+ * Differences: framework-free class, an in-flight tick survives option
23
+ * updates that do not change the next unit (a log whose `total` grows while
24
+ * streaming does not stall), and switching from controlled back to timer
25
+ * mode resumes from the controlled count instead of jumping back.
26
+ */
27
+ export const DEFAULT_STEP_INTERVAL = 850;
28
+ export const DEFAULT_START_DELAY = 320;
29
+ const realClock = {
30
+ setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
31
+ clearTimeout: (id) => globalThis.clearTimeout(id)
32
+ };
33
+ function clampCount(n, total) {
34
+ if (!Number.isFinite(n))
35
+ return n > 0 ? total : 0;
36
+ return Math.max(0, Math.min(Math.floor(n), total));
37
+ }
38
+ /** The delay before unit `index` (pure; exported for tests and hosts). */
39
+ export function revealDelay(index, opts) {
40
+ const raw = opts.delayFor
41
+ ? opts.delayFor(index)
42
+ : index === 0
43
+ ? (opts.startDelay ?? DEFAULT_START_DELAY)
44
+ : (opts.stepInterval ?? DEFAULT_STEP_INTERVAL);
45
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
46
+ }
47
+ export class RevealCore {
48
+ #opts;
49
+ #hooks;
50
+ #clock;
51
+ #ticked = 0;
52
+ #timer = null;
53
+ /** The unit index the pending timer will reveal. */
54
+ #pendingFor = -1;
55
+ #fired = false;
56
+ #active = false;
57
+ #last = -1;
58
+ constructor(opts, hooks = {}) {
59
+ this.#opts = { ...opts };
60
+ this.#hooks = hooks;
61
+ this.#clock = hooks.clock ?? realClock;
62
+ }
63
+ get total() {
64
+ return Math.max(0, Math.floor(this.#opts.total || 0));
65
+ }
66
+ get controlled() {
67
+ return this.#opts.revealed !== undefined;
68
+ }
69
+ /** Units currently visible. */
70
+ get revealed() {
71
+ const total = this.total;
72
+ return this.controlled
73
+ ? clampCount(this.#opts.revealed, total)
74
+ : clampCount(this.#ticked, total);
75
+ }
76
+ get complete() {
77
+ return this.total > 0 && this.revealed >= this.total;
78
+ }
79
+ /** True while a tick is scheduled. */
80
+ get pending() {
81
+ return this.#timer !== null;
82
+ }
83
+ /** Begin scheduling (call on mount — never during SSR). */
84
+ start() {
85
+ this.#active = true;
86
+ this.#sync();
87
+ }
88
+ /** Merge new options (any subset) and reconcile. */
89
+ update(next) {
90
+ const wasControlled = this.controlled;
91
+ const before = this.revealed;
92
+ this.#opts = { ...this.#opts, ...next };
93
+ if ('revealed' in next && next.revealed === undefined)
94
+ this.#opts.revealed = undefined;
95
+ // Leaving controlled mode: carry on from where the events left the count.
96
+ if (wasControlled && !this.controlled)
97
+ this.#ticked = before;
98
+ this.#ticked = Math.min(this.#ticked, this.total);
99
+ this.#sync();
100
+ }
101
+ /** Back to zero and run again (timer mode). */
102
+ replay() {
103
+ this.#ticked = 0;
104
+ this.#fired = false;
105
+ this.#cancel();
106
+ this.#sync();
107
+ }
108
+ /** Stop the timer for good (call on unmount). */
109
+ destroy() {
110
+ this.#active = false;
111
+ this.#cancel();
112
+ }
113
+ #cancel() {
114
+ if (this.#timer !== null)
115
+ this.#clock.clearTimeout(this.#timer);
116
+ this.#timer = null;
117
+ this.#pendingFor = -1;
118
+ }
119
+ #sync() {
120
+ this.#emit();
121
+ this.#checkComplete();
122
+ this.#schedule();
123
+ }
124
+ #emit() {
125
+ const n = this.revealed;
126
+ if (n !== this.#last) {
127
+ this.#last = n;
128
+ this.#hooks.onChange?.(n);
129
+ }
130
+ }
131
+ #checkComplete() {
132
+ if (!this.complete) {
133
+ this.#fired = false;
134
+ return;
135
+ }
136
+ if (this.#fired)
137
+ return;
138
+ this.#fired = true;
139
+ this.#hooks.onComplete?.();
140
+ }
141
+ #schedule() {
142
+ const n = this.revealed;
143
+ const canRun = this.#active && !this.controlled && this.#opts.run !== false && n < this.total;
144
+ if (!canRun) {
145
+ this.#cancel();
146
+ return;
147
+ }
148
+ // A tick for this very unit is already on its way: leave it alone.
149
+ if (this.#timer !== null && this.#pendingFor === n)
150
+ return;
151
+ this.#cancel();
152
+ this.#pendingFor = n;
153
+ this.#timer = this.#clock.setTimeout(() => {
154
+ this.#timer = null;
155
+ this.#pendingFor = -1;
156
+ this.#ticked = Math.min(this.#ticked + 1, this.total);
157
+ this.#sync();
158
+ }, revealDelay(n, this.#opts));
159
+ }
160
+ }
@@ -0,0 +1,34 @@
1
+ import { type RevealClock } from './revealCore';
2
+ type MaybeGetter<T> = T | (() => T);
3
+ export interface RevealTickerOptions {
4
+ /** How many units the log has in total. */
5
+ total: MaybeGetter<number>;
6
+ /** ms between units after the first (timer mode). Default 850. */
7
+ stepInterval?: MaybeGetter<number>;
8
+ /** ms before the first unit (timer mode). Default 320. */
9
+ startDelay?: MaybeGetter<number>;
10
+ /**
11
+ * Drive the count yourself from real events. While this resolves to a
12
+ * number the timer is off; resolve to `undefined` to hand back to it.
13
+ * Pass a getter (`() => count`) to keep it reactive.
14
+ */
15
+ revealed?: (() => number | undefined) | number;
16
+ /** Dwell before unit `index` appears (overrides startDelay/stepInterval). */
17
+ delayFor?: (index: number) => number;
18
+ /** false pauses the timer. Default true. */
19
+ run?: MaybeGetter<boolean>;
20
+ /** Fired once when the last unit lands (re-armed if the count drops). */
21
+ onComplete?: () => void;
22
+ /** Injectable clock (tests). */
23
+ clock?: RevealClock;
24
+ }
25
+ export interface RevealTicker {
26
+ /** Units visible right now. */
27
+ readonly revealed: number;
28
+ /** Every unit is visible (total > 0). */
29
+ readonly complete: boolean;
30
+ /** Restart from zero (timer mode). */
31
+ replay(): void;
32
+ }
33
+ export declare function createRevealTicker(opts: RevealTickerOptions): RevealTicker;
34
+ export {};
@@ -0,0 +1,73 @@
1
+ /* revealTicker.svelte.js generated by Svelte v5.53.11 */
2
+ import * as $ from 'svelte/internal/client';
3
+ import { untrack } from 'svelte';
4
+ import { RevealCore } from './revealCore';
5
+
6
+ function read(v) {
7
+ return typeof v === 'function' ? v() : v;
8
+ }
9
+
10
+ function resolve(opts) {
11
+ return {
12
+ total: read(opts.total) ?? 0,
13
+ stepInterval: read(opts.stepInterval),
14
+ startDelay: read(opts.startDelay),
15
+ revealed: read(opts.revealed),
16
+ delayFor: opts.delayFor,
17
+ run: read(opts.run)
18
+ };
19
+ }
20
+
21
+ export function createRevealTicker(opts) {
22
+ let revealed = $.state(0);
23
+ let complete = $.state(false);
24
+
25
+ const core = new RevealCore(resolve(opts), {
26
+ clock: opts.clock,
27
+ onChange: (n) => {
28
+ $.set(revealed, n, true);
29
+ $.set(complete, core.complete, true);
30
+ },
31
+
32
+ onComplete: () => {
33
+ $.set(complete, true);
34
+ opts.onComplete?.();
35
+ }
36
+ });
37
+
38
+ // SSR / first paint: the controlled count is already known.
39
+ $.set(revealed, core.revealed, true);
40
+
41
+ $.set(complete, core.complete, true);
42
+
43
+ let started = false;
44
+
45
+ $.user_effect(() => {
46
+ const next = resolve(opts);
47
+
48
+ // Only the options are dependencies — never state read by onComplete.
49
+ untrack(() => {
50
+ core.update(next);
51
+
52
+ if (!started) {
53
+ started = true;
54
+ core.start();
55
+ }
56
+
57
+ $.set(complete, core.complete, true);
58
+ });
59
+ });
60
+
61
+ $.user_effect(() => () => core.destroy());
62
+
63
+ return {
64
+ get revealed() {
65
+ return $.get(revealed);
66
+ },
67
+
68
+ get complete() {
69
+ return $.get(complete);
70
+ },
71
+ replay: () => core.replay()
72
+ };
73
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Block splitter behind StreamedText (jatui-lltq9).
3
+ *
4
+ * Streamed assistant text is append-only, so the split has to be too: for any
5
+ * prefix of the final text, every block but the last is IDENTICAL to the
6
+ * final text's block at that index, and the last block's text is a PREFIX of
7
+ * the final block. That is what lets each block mount once (keyed by index)
8
+ * and then only grow — nothing restarts, nothing reflows backwards.
9
+ *
10
+ * Rules that keep that guarantee:
11
+ * - The text after the last "\n" is the in-progress line; it never closes
12
+ * a block, even when it is blank so far.
13
+ * - Block text is right-trimmed, so a half-typed trailing space or a
14
+ * dangling newline never has to be "taken back" later.
15
+ * - 'paragraph' mode: a blank line closes a block, except inside a fenced
16
+ * code block (``` or ~~~) — a markdown `renderBlock` sees whole fences.
17
+ * - 'line' mode: each non-blank line is a block.
18
+ */
19
+ export type StreamedTextSplit = 'paragraph' | 'line';
20
+ export interface StreamedBlock {
21
+ /** Stable key: the block's index ("b0", "b1", …). */
22
+ key: string;
23
+ /** Block text, right-trimmed. */
24
+ text: string;
25
+ /** True once a later separator closed the block — it can no longer grow. */
26
+ complete: boolean;
27
+ }
28
+ export declare function splitStreamedText(text: string, split?: StreamedTextSplit): StreamedBlock[];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Block splitter behind StreamedText (jatui-lltq9).
3
+ *
4
+ * Streamed assistant text is append-only, so the split has to be too: for any
5
+ * prefix of the final text, every block but the last is IDENTICAL to the
6
+ * final text's block at that index, and the last block's text is a PREFIX of
7
+ * the final block. That is what lets each block mount once (keyed by index)
8
+ * and then only grow — nothing restarts, nothing reflows backwards.
9
+ *
10
+ * Rules that keep that guarantee:
11
+ * - The text after the last "\n" is the in-progress line; it never closes
12
+ * a block, even when it is blank so far.
13
+ * - Block text is right-trimmed, so a half-typed trailing space or a
14
+ * dangling newline never has to be "taken back" later.
15
+ * - 'paragraph' mode: a blank line closes a block, except inside a fenced
16
+ * code block (``` or ~~~) — a markdown `renderBlock` sees whole fences.
17
+ * - 'line' mode: each non-blank line is a block.
18
+ */
19
+ const FENCE = /^\s{0,3}(```|~~~)/;
20
+ export function splitStreamedText(text, split = 'paragraph') {
21
+ if (!text)
22
+ return [];
23
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
24
+ const last = lines.length - 1; // index of the in-progress line
25
+ const blocks = [];
26
+ let open = null;
27
+ let inFence = false;
28
+ const close = (complete) => {
29
+ if (!open)
30
+ return;
31
+ const body = open.join('\n').trimEnd();
32
+ if (body)
33
+ blocks.push({ key: `b${blocks.length}`, text: body, complete });
34
+ open = null;
35
+ };
36
+ for (let i = 0; i <= last; i++) {
37
+ const line = lines[i];
38
+ const blank = line.trim() === '';
39
+ const partial = i === last;
40
+ if (split === 'line') {
41
+ if (!blank)
42
+ blocks.push({ key: `b${blocks.length}`, text: line.trimEnd(), complete: !partial });
43
+ continue;
44
+ }
45
+ if (blank && !inFence) {
46
+ // A blank in-progress line has not closed anything yet.
47
+ if (!partial)
48
+ close(true);
49
+ continue;
50
+ }
51
+ if (!open)
52
+ open = [];
53
+ open.push(line);
54
+ if (FENCE.test(line) && !partial)
55
+ inFence = !inFence;
56
+ }
57
+ close(false);
58
+ return blocks;
59
+ }
@@ -4,6 +4,13 @@
4
4
  Rich text input for composing and sending messages.
5
5
  Supports mentions, attachments, and drag/drop.
6
6
  All searches and uploads are via callbacks.
7
+
8
+ Agent composers (opt-in, jatui-lltq9): `busy` + `onStop` swap the send
9
+ button for a same-size Stop button (aria-label "Stop generating") while a
10
+ reply is generating; Escape inside the input also stops (an open mention
11
+ list still takes Escape first). Sending is held while busy — the draft is
12
+ kept. `statusTab` renders a slim strip attached to the input's top edge
13
+ (e.g. model · message count). Pair with ComposerGlow for the rim light.
7
14
  -->
8
15
 
9
16
  <script lang="ts">
@@ -18,6 +25,7 @@
18
25
  } from '../../utils/mentionParser'
19
26
  import MentionAutocomplete from './MentionAutocomplete.svelte'
20
27
  import QuotedMessageDisplay from './QuotedMessageDisplay.svelte'
28
+ import type { Snippet } from 'svelte'
21
29
 
22
30
  interface Props {
23
31
  placeholder?: string
@@ -37,6 +45,12 @@
37
45
  ontyping?: (data: { isTyping: boolean }) => void
38
46
  onquoteremove?: () => void
39
47
  onsearchmentions?: (query: string) => Promise<MentionItem[]>
48
+ /** A reply is generating: the send slot shows Stop, Escape stops, sending is held. */
49
+ busy?: boolean
50
+ /** Called by the Stop button / Escape while `busy`. */
51
+ onStop?: () => void
52
+ /** Slim strip attached to the input's top edge (status chrome). */
53
+ statusTab?: Snippet
40
54
  }
41
55
 
42
56
  let {
@@ -52,6 +66,9 @@
52
66
  ontyping,
53
67
  onquoteremove,
54
68
  onsearchmentions,
69
+ busy = false,
70
+ onStop,
71
+ statusTab,
55
72
  }: Props = $props()
56
73
 
57
74
  let selectedPriority = $state<'low' | 'normal' | 'high' | 'urgent'>('normal')
@@ -203,6 +220,7 @@
203
220
 
204
221
  function handleKeyDown(event: KeyboardEvent) {
205
222
  if (showMentionAutocomplete && ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'].includes(event.key)) return
223
+ if (event.key === 'Escape' && busy && onStop) { event.preventDefault(); event.stopPropagation(); onStop(); return }
206
224
  if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage() }
207
225
  }
208
226
 
@@ -255,7 +273,7 @@
255
273
 
256
274
  function sendMessage() {
257
275
  const content = inputText.trim()
258
- if ((!content && attachments.length === 0) || disabled) return
276
+ if ((!content && attachments.length === 0) || disabled || busy) return
259
277
 
260
278
  onsend?.({
261
279
  content,
@@ -368,6 +386,12 @@
368
386
  </div>
369
387
  {/if}
370
388
 
389
+ {#if statusTab}
390
+ <div class="self-start -mb-2 ml-3 max-w-[calc(100%-1.5rem)] truncate px-2.5 py-0.5 text-xs text-base-content/60 bg-base-200 border border-b-0 border-base-300 rounded-t-lg">
391
+ {@render statusTab()}
392
+ </div>
393
+ {/if}
394
+
371
395
  <div class="flex items-end gap-2 {compact ? 'p-2' : 'p-3'} border border-base-300 rounded-lg focus-within:border-primary transition-colors">
372
396
  {#if !compact}
373
397
  <button type="button" class="btn btn-ghost btn-sm btn-circle flex-shrink-0" onclick={handleFileUpload} title="Attach file" {disabled}>📎</button>
@@ -412,9 +436,15 @@
412
436
  </select>
413
437
  {/if}
414
438
 
415
- <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={sendMessage} disabled={disabled || (!inputText.trim() && attachments.length === 0) || isOverLimit} title="Send message (Enter)">
416
- ➤
417
- </button>
439
+ {#if busy}
440
+ <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={() => onStop?.()} disabled={!onStop} aria-label="Stop generating" title="Stop generating (Esc)">
441
+ <svg viewBox="0 0 16 16" class="w-3 h-3" fill="currentColor" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="2" /></svg>
442
+ </button>
443
+ {:else}
444
+ <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={sendMessage} disabled={disabled || (!inputText.trim() && attachments.length === 0) || isOverLimit} title="Send message (Enter)">
445
+ ➤
446
+ </button>
447
+ {/if}
418
448
  </div>
419
449
 
420
450
  <div class="text-xs text-base-content/50">
@@ -1,4 +1,5 @@
1
1
  import type { QuotedMessage, PendingAttachment, MentionItem, AttachmentCallbacks } from '../../types/messaging';
2
+ import type { Snippet } from 'svelte';
2
3
  interface Props {
3
4
  placeholder?: string;
4
5
  disabled?: boolean;
@@ -19,6 +20,12 @@ interface Props {
19
20
  }) => void;
20
21
  onquoteremove?: () => void;
21
22
  onsearchmentions?: (query: string) => Promise<MentionItem[]>;
23
+ /** A reply is generating: the send slot shows Stop, Escape stops, sending is held. */
24
+ busy?: boolean;
25
+ /** Called by the Stop button / Escape while `busy`. */
26
+ onStop?: () => void;
27
+ /** Slim strip attached to the input's top edge (status chrome). */
28
+ statusTab?: Snippet;
22
29
  }
23
30
  declare const MessageInput: import("svelte").Component<Props, {
24
31
  focus: () => void;
@@ -290,7 +290,7 @@
290
290
  {#if !isConsecutive}
291
291
  <Avatar name={message.senderName} avatarUrl={message.senderAvatar} size="small" />
292
292
  {:else}
293
- <div class="text-xs text-base-content/40 opacity-0 group-hover:opacity-100 transition-opacity text-center pt-0.5">
293
+ <div class="text-xs text-base-content/40 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity text-center pt-0.5">
294
294
  {formatTime(message.createdAt).split(' ')[1] || ''}
295
295
  </div>
296
296
  {/if}
@@ -299,7 +299,7 @@
299
299
  <!-- Content -->
300
300
  <div class="flex-1 min-w-0 relative">
301
301
  <!-- Floating action buttons -->
302
- <div class="absolute right-0 {!isConsecutive ? 'top-0' : 'top-1'} opacity-0 group-hover:opacity-100 transition-opacity bg-base-200 rounded-lg border border-base-300 p-1 z-10">
302
+ <div class="absolute right-0 {!isConsecutive ? 'top-0' : 'top-1'} opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity bg-base-200 rounded-lg border border-base-300 p-1 z-10">
303
303
  <div class="flex items-center">
304
304
  {#if reactionCallbacks && currentUserId}
305
305
  <div class="flex items-center gap-1 mr-1">
@@ -0,0 +1,65 @@
1
+ export type NotificationStatus = 'info' | 'success' | 'warning' | 'error';
2
+ export interface NotificationAction {
3
+ label: string;
4
+ onSelect: (item: NotificationItem) => void;
5
+ /** Button weight. Default 'secondary' (a quiet outline button). */
6
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
7
+ }
8
+ export interface NotificationItem {
9
+ id: string;
10
+ title: string;
11
+ description?: string;
12
+ /** When it happened — Date, ISO/DB timestamp string, or epoch ms. */
13
+ timestamp: Date | string | number;
14
+ /** Host-owned read flag. Missing / false = unread. */
15
+ read?: boolean;
16
+ /** Free-form category the tabs filter on (defaults: 'mentions', 'system'). */
17
+ category?: string;
18
+ /** Status glyph shown when there is no avatar. */
19
+ status?: NotificationStatus;
20
+ avatar?: {
21
+ name: string;
22
+ src?: string | null;
23
+ };
24
+ actions?: NotificationAction[];
25
+ /** Opening the item follows this link. */
26
+ href?: string;
27
+ }
28
+ export interface NotificationTab {
29
+ id: string;
30
+ label: string;
31
+ /** Which items the tab shows. Omitted = every item. */
32
+ filter?: (item: NotificationItem) => boolean;
33
+ }
34
+ /** All / Mentions / System, filtered by `category` ('mention(s)' / 'system'). */
35
+ export declare const DEFAULT_NOTIFICATION_TABS: NotificationTab[];
36
+ export type DateGroupKey = 'today' | 'yesterday' | 'week' | 'earlier';
37
+ export declare const DATE_GROUP_LABELS: Record<DateGroupKey, string>;
38
+ /** Epoch ms for a timestamp, or NaN when it cannot be parsed. */
39
+ export declare function toTimestampMs(ts: Date | string | number | null | undefined): number;
40
+ /**
41
+ * Which date bucket a timestamp falls in, by LOCAL calendar day relative to
42
+ * `now`: today, yesterday, the 5 days before that ("This week" = within the
43
+ * last 7 calendar days), else earlier. Future and unparseable timestamps land
44
+ * in 'today' / 'earlier' respectively.
45
+ */
46
+ export declare function dateGroupKey(ts: Date | string | number, now?: Date | number): DateGroupKey;
47
+ export interface NotificationGroup<T extends NotificationItem = NotificationItem> {
48
+ key: DateGroupKey;
49
+ label: string;
50
+ items: T[];
51
+ }
52
+ /**
53
+ * Newest-first items bucketed under Today / Yesterday / This week / Earlier.
54
+ * Empty buckets are dropped; ties keep input order (stable sort); items with
55
+ * an unparseable timestamp sort last.
56
+ */
57
+ export declare function groupNotificationsByDate<T extends NotificationItem>(items: readonly T[], now?: Date | number): NotificationGroup<T>[];
58
+ /** Unread = host says not read AND not optimistically read locally. */
59
+ export declare function isNotificationUnread(item: NotificationItem, localRead?: ReadonlySet<string>): boolean;
60
+ export declare function unreadIds(items: readonly NotificationItem[], localRead?: ReadonlySet<string>): string[];
61
+ export declare function filterByTab<T extends NotificationItem>(items: readonly T[], tab: NotificationTab | undefined): T[];
62
+ /** Item count per tab id (what the tab chips show). */
63
+ export declare function tabCounts(items: readonly NotificationItem[], tabs: readonly NotificationTab[]): Record<string, number>;
64
+ /** The header sentence: "3 unread" / "No unread notifications". */
65
+ export declare function unreadSummary(count: number): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * NotificationCenter — pure data helpers (DOM-free, unit-tested in
3
+ * tests/unit/notificationCenter.test.ts).
4
+ *
5
+ * The data model is adapted from BoardUI's notification-center.tsx
6
+ * (MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)):
7
+ * `category` / `status` / `avatar` / `actions[]` and the All / Mentions /
8
+ * System tab split. jatui changes: real timestamps (Date | string | epoch ms)
9
+ * grouped into calendar date buckets, configurable tabs, host-owned read state.
10
+ */
11
+ import { parseTimestamp } from '../utils/dateFormatters';
12
+ const inCategory = (...names) => (item) => item.category != null && names.includes(item.category.toLowerCase());
13
+ /** All / Mentions / System, filtered by `category` ('mention(s)' / 'system'). */
14
+ export const DEFAULT_NOTIFICATION_TABS = [
15
+ { id: 'all', label: 'All' },
16
+ { id: 'mentions', label: 'Mentions', filter: inCategory('mentions', 'mention') },
17
+ { id: 'system', label: 'System', filter: inCategory('system') }
18
+ ];
19
+ export const DATE_GROUP_LABELS = {
20
+ today: 'Today',
21
+ yesterday: 'Yesterday',
22
+ week: 'This week',
23
+ earlier: 'Earlier'
24
+ };
25
+ const DATE_GROUP_ORDER = ['today', 'yesterday', 'week', 'earlier'];
26
+ /** Epoch ms for a timestamp, or NaN when it cannot be parsed. */
27
+ export function toTimestampMs(ts) {
28
+ if (ts == null || ts === '')
29
+ return NaN;
30
+ if (typeof ts === 'number')
31
+ return Number.isFinite(ts) ? ts : NaN;
32
+ const d = parseTimestamp(ts);
33
+ return d ? d.getTime() : NaN;
34
+ }
35
+ /** Local midnight of the day containing `ms`. */
36
+ function startOfDay(ms) {
37
+ const d = new Date(ms);
38
+ d.setHours(0, 0, 0, 0);
39
+ return d.getTime();
40
+ }
41
+ /**
42
+ * Which date bucket a timestamp falls in, by LOCAL calendar day relative to
43
+ * `now`: today, yesterday, the 5 days before that ("This week" = within the
44
+ * last 7 calendar days), else earlier. Future and unparseable timestamps land
45
+ * in 'today' / 'earlier' respectively.
46
+ */
47
+ export function dateGroupKey(ts, now = Date.now()) {
48
+ const ms = toTimestampMs(ts);
49
+ if (Number.isNaN(ms))
50
+ return 'earlier';
51
+ const today = startOfDay(typeof now === 'number' ? now : now.getTime());
52
+ const day = startOfDay(ms);
53
+ if (day >= today)
54
+ return 'today';
55
+ // Calendar-day difference, rounded so DST shifts (23h / 25h days) don't skew it.
56
+ const days = Math.round((today - day) / 86_400_000);
57
+ if (days === 1)
58
+ return 'yesterday';
59
+ if (days < 7)
60
+ return 'week';
61
+ return 'earlier';
62
+ }
63
+ /**
64
+ * Newest-first items bucketed under Today / Yesterday / This week / Earlier.
65
+ * Empty buckets are dropped; ties keep input order (stable sort); items with
66
+ * an unparseable timestamp sort last.
67
+ */
68
+ export function groupNotificationsByDate(items, now = Date.now()) {
69
+ const sorted = items
70
+ .map((item, i) => ({ item, i, ms: toTimestampMs(item.timestamp) }))
71
+ .sort((a, b) => {
72
+ const am = Number.isNaN(a.ms) ? -Infinity : a.ms;
73
+ const bm = Number.isNaN(b.ms) ? -Infinity : b.ms;
74
+ return bm - am || a.i - b.i;
75
+ });
76
+ const buckets = new Map();
77
+ for (const { item } of sorted) {
78
+ const key = dateGroupKey(item.timestamp, now);
79
+ let list = buckets.get(key);
80
+ if (!list)
81
+ buckets.set(key, (list = []));
82
+ list.push(item);
83
+ }
84
+ return DATE_GROUP_ORDER.filter((k) => buckets.has(k)).map((key) => ({
85
+ key,
86
+ label: DATE_GROUP_LABELS[key],
87
+ items: buckets.get(key)
88
+ }));
89
+ }
90
+ /** Unread = host says not read AND not optimistically read locally. */
91
+ export function isNotificationUnread(item, localRead) {
92
+ return item.read !== true && !(localRead?.has(item.id) ?? false);
93
+ }
94
+ export function unreadIds(items, localRead) {
95
+ return items.filter((it) => isNotificationUnread(it, localRead)).map((it) => it.id);
96
+ }
97
+ export function filterByTab(items, tab) {
98
+ return tab?.filter ? items.filter((it) => tab.filter(it)) : items.slice();
99
+ }
100
+ /** Item count per tab id (what the tab chips show). */
101
+ export function tabCounts(items, tabs) {
102
+ const out = {};
103
+ for (const tab of tabs)
104
+ out[tab.id] = tab.filter ? items.filter(tab.filter).length : items.length;
105
+ return out;
106
+ }
107
+ /** The header sentence: "3 unread" / "No unread notifications". */
108
+ export function unreadSummary(count) {
109
+ return count === 0 ? 'No unread notifications' : `${count} unread`;
110
+ }