@joewinke/jatui 0.38.6 → 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.
- package/CLAUDE.md +31 -0
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/dist/components/BulkActionBar.svelte +32 -2
- package/dist/components/BulkActionBar.svelte.d.ts +5 -1
- package/dist/components/FloatingActionBar.svelte +102 -34
- package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
- package/dist/components/MediaExplorer.svelte +249 -0
- package/dist/components/MediaExplorer.svelte.d.ts +37 -0
- package/dist/components/NotificationCenter.svelte +481 -0
- package/dist/components/NotificationCenter.svelte.d.ts +29 -0
- package/dist/components/SearchDropdown.svelte +54 -8
- package/dist/components/SearchDropdown.svelte.d.ts +12 -0
- package/dist/components/agent/AgentLog.svelte +217 -0
- package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
- package/dist/components/agent/AgentLogRow.svelte +281 -0
- package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
- package/dist/components/agent/AgentTaskList.svelte +607 -0
- package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
- package/dist/components/agent/AgentThinking.svelte +280 -0
- package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
- package/dist/components/agent/ComposerGlow.svelte +265 -0
- package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
- package/dist/components/agent/ContextMeter.svelte +277 -0
- package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
- package/dist/components/agent/Questionnaire.svelte +767 -0
- package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
- package/dist/components/agent/ShimmerText.svelte +100 -0
- package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
- package/dist/components/agent/StreamedText.svelte +135 -0
- package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
- package/dist/components/agent/agentLog.d.ts +22 -0
- package/dist/components/agent/agentLog.js +26 -0
- package/dist/components/agent/agentTaskList.d.ts +90 -0
- package/dist/components/agent/agentTaskList.js +93 -0
- package/dist/components/agent/agentThinking.d.ts +46 -0
- package/dist/components/agent/agentThinking.js +78 -0
- package/dist/components/agent/contextMeter.d.ts +97 -0
- package/dist/components/agent/contextMeter.js +156 -0
- package/dist/components/agent/questionnaire.d.ts +104 -0
- package/dist/components/agent/questionnaire.js +225 -0
- package/dist/components/agent/revealCore.d.ts +74 -0
- package/dist/components/agent/revealCore.js +160 -0
- package/dist/components/agent/revealTicker.d.ts +34 -0
- package/dist/components/agent/revealTicker.js +73 -0
- package/dist/components/agent/streamedText.d.ts +28 -0
- package/dist/components/agent/streamedText.js +59 -0
- package/dist/components/messaging/MessageInput.svelte +34 -4
- package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
- package/dist/components/messaging/MessageItem.svelte +2 -2
- package/dist/components/notificationCenter.d.ts +65 -0
- package/dist/components/notificationCenter.js +110 -0
- package/dist/components/progress/Steps.svelte +269 -5
- package/dist/components/progress/Steps.svelte.d.ts +16 -1
- package/dist/components/progress/stepsSummary.d.ts +14 -0
- package/dist/components/progress/stepsSummary.js +32 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +29 -0
- package/dist/selection/clickSelection.d.ts +89 -0
- package/dist/selection/clickSelection.js +152 -0
- package/dist/selection/createClickSelection.d.ts +79 -0
- package/dist/selection/createClickSelection.js +134 -0
- 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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|