@getaster/elements-preview 0.0.0-stage → 0.1.0-preview.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,576 @@
1
+ import { AsterElement, Menu, compositeTypes as t, define, each } from "@getaster/elements";
2
+ import { html, svg, nothing } from "lit-html";
3
+ import { ConversationSession } from "./session.js";
4
+ const messageIcons = {
5
+ copy: svg `<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1"/><path d="M10.5 5.5v-3h-8v8h3"/></svg>`,
6
+ reply: svg `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6.5 3-4 4 4 4M3 7h5a5 5 0 0 1 5 5v1"/></svg>`,
7
+ };
8
+ /** The independently retained reading region used by ConversationReader. */
9
+ export class ConversationPane extends AsterElement {
10
+ #session = this.prop(undefined);
11
+ #renderer = this.prop(undefined);
12
+ #context = this.prop(false);
13
+ #passages = this.prop(false);
14
+ #following = this.prop(true);
15
+ #arrivals = this.prop(0);
16
+ #status = this.prop("");
17
+ #selection = this.prop(undefined);
18
+ #auto = this.prop(true);
19
+ #focusEnabled = this.prop(false);
20
+ #readingOnly = this.prop(false);
21
+ #restoreGeneration = 0;
22
+ #selectionRange;
23
+ #anchor;
24
+ #ids = [];
25
+ #programmatic;
26
+ #resize;
27
+ #selectedTarget;
28
+ #scroller;
29
+ #previous = [];
30
+ #current;
31
+ #focusedId;
32
+ get session() { return this.#session.value; }
33
+ set session(value) { this.#session.value = value; }
34
+ get entryRenderer() { return this.#renderer.value; }
35
+ set entryRenderer(value) { this.#renderer.value = value; }
36
+ get contextEnabled() { return this.#context.value; }
37
+ set contextEnabled(value) { this.#context.value = value; }
38
+ get passagesEnabled() { return this.#passages.value; }
39
+ set passagesEnabled(value) { this.#passages.value = value; if (!value)
40
+ this.#selection.value = undefined; }
41
+ get autoLoadHistory() { return this.#auto.value; }
42
+ set autoLoadHistory(value) { this.#auto.value = value; }
43
+ get focusEnabled() { return this.#focusEnabled.value; }
44
+ set focusEnabled(value) { this.#focusEnabled.value = value; }
45
+ get readingOnly() { return this.#readingOnly.value; }
46
+ set readingOnly(value) { this.#readingOnly.value = value; }
47
+ connectedCallback() {
48
+ super.connectedCallback();
49
+ this.#resize = new ResizeObserver(() => this.#restore());
50
+ const content = this.querySelector(".ac-entries");
51
+ if (content)
52
+ this.#resize.observe(content);
53
+ if (this.#scroller)
54
+ this.#resize.observe(this.#scroller);
55
+ this.onDisconnect(() => this.#resize?.disconnect());
56
+ this.listen(document, "selectionchange", () => this.#readSelection());
57
+ this.listen(this, "contextmenu", event => {
58
+ if (event.target.closest("aster-menu,textarea,input,[contenteditable]"))
59
+ return;
60
+ this.#readSelection();
61
+ if (!this.#selection.peek())
62
+ return;
63
+ event.preventDefault();
64
+ this.#showSelectionMenu({ x: event.clientX, y: event.clientY });
65
+ });
66
+ this.listen(this, "focusin", event => { this.#focusedId = event.target.closest("article[data-entry]")?.dataset["entry"]; });
67
+ this.listen(this, "focusout", event => {
68
+ const target = event.target;
69
+ if (event.relatedTarget && !this.contains(event.relatedTarget))
70
+ this.#focusedId = undefined;
71
+ else if (!event.relatedTarget)
72
+ queueMicrotask(() => { if (target.isConnected && !target.contains(document.activeElement))
73
+ this.#focusedId = undefined; });
74
+ });
75
+ }
76
+ /** Restore after a hidden region or responsive layout becomes visible. */
77
+ restore() { requestAnimationFrame(() => { if (this.isConnected)
78
+ this.#restore(); }); }
79
+ focusReading() { this.#scroller?.focus({ preventScroll: true }); this.restore(); }
80
+ location() {
81
+ this.#capture();
82
+ return { ...(this.#anchor ? { entryId: this.#anchor.id } : {}), offset: this.#anchor?.offset ?? 0, following: this.#following.peek() };
83
+ }
84
+ async restoreLocation(location) {
85
+ const session = this.#session.peek(), generation = ++this.#restoreGeneration;
86
+ if (!session)
87
+ return;
88
+ if (location.following && session.hasNewer.peek())
89
+ await session.open();
90
+ else if (location.entryId && !session.entries.peek().some(entry => entry.id === location.entryId))
91
+ await session.open({ entryId: location.entryId });
92
+ if (!this.isConnected || this.#session.peek() !== session || generation !== this.#restoreGeneration)
93
+ return;
94
+ this.#following.value = location.following;
95
+ if (location.entryId)
96
+ this.#anchor = { id: location.entryId, offset: location.offset, index: 0 };
97
+ this.restore();
98
+ }
99
+ focusSource(entryId) {
100
+ const article = this.#article(entryId);
101
+ (article?.querySelector(".ac-entry-actions button") ?? article ?? this.#scroller)?.focus({ preventScroll: true });
102
+ this.restore();
103
+ }
104
+ receive(entry) {
105
+ const accepted = this.#session.peek()?.receive(entry, this.#protectedIds()) ?? false;
106
+ if (!accepted) {
107
+ this.#arrivals.value++;
108
+ this.#status.value = "New activity is available. Use Latest to catch up.";
109
+ }
110
+ return accepted;
111
+ }
112
+ reveal(entryId) {
113
+ const article = this.#article(entryId);
114
+ if (!article)
115
+ return;
116
+ this.#following.value = false;
117
+ this.#anchor = { id: entryId, offset: 12, index: this.#ids.indexOf(entryId) };
118
+ this.#restore();
119
+ article.focus({ preventScroll: true });
120
+ }
121
+ render() {
122
+ const session = this.#session.value;
123
+ if (!session)
124
+ return html `<p class="ac-empty">Choose a conversation.</p>`;
125
+ const entries = session.entries.value, busy = session.busy.value;
126
+ const renderer = this.#renderer.value;
127
+ return html `<header class="ac-pane-header"><div><p class="ac-kicker">Conversation</p><h2>${session.title}</h2></div>${this.#focusEnabled.value ? html `<button class="ac-focus-launch" type="button" aria-haspopup="dialog" aria-label=${`Read ${session.title} in focused reader`} title="Open focused reader" @click=${(event) => { this.#capture(); this.emit("conversation-focus", { pane: this, opener: event.currentTarget }); }}>↗</button>` : nothing}</header>
128
+ <div class="ac-tools"><span>${this.#following.value ? "Following latest" : "Reading history"}</span><div><button type="button" @click=${() => this.#step(-1)} aria-label="Previous message">↑</button><button type="button" @click=${() => this.#step(1)} aria-label="Next message">↓</button><button type="button" @click=${() => this.#latest()}>Latest ↓</button></div></div>
129
+ <div class="ac-scroll" tabindex="0" role="region" aria-label=${`${session.title}: message history`} aria-busy=${busy !== undefined} @scroll=${() => this.#scroll()} @keydown=${(event) => this.#keys(event)}>
130
+ <div class="ac-edge"><button type="button" ?disabled=${!!busy || !session.hasOlder.value} @click=${() => this.#older()}>${session.hasOlder.value ? "Load earlier messages" : entries.length ? "Beginning of loaded history" : "No messages loaded"}</button></div>
131
+ <ol class="ac-entries">${each(entries, entry => entry.id, entry => html `<li><article class="ac-entry" data-entry=${entry.id} data-kind=${entry.kind ?? "message"} tabindex="-1" aria-label=${`${entry.author}, ${entry.time}`}><header><strong>${entry.author}</strong><time>${entry.time}</time>${entry.kind === "note" ? html `<span class="ac-badge">Internal note</span>` : nothing}</header>
132
+ <div class="ac-message-controls" role="group" aria-label=${`Actions for ${entry.author}’s message`}>
133
+ <button type="button" aria-label="Copy message" title="Copy message" @click=${() => void this.#copyText(entry.text, "Message copied.")}>${messageIcons.copy}</button>
134
+ ${!this.#readingOnly.value && session.adapter.send && entry.kind !== "event" ? html `<button type="button" aria-label="Reply to conversation" title="Reply to conversation" @click=${() => this.querySelector(".ac-composer textarea")?.focus({ preventScroll: true })}>${messageIcons.reply}</button>` : nothing}
135
+ </div>
136
+ ${renderer ? renderer(entry) : html `<div class="ac-body" data-passage-body>${entry.text}</div>`}
137
+ <div class="ac-entry-actions">${this.#context.value && entry.discussion ? html `<button type="button" @click=${() => this.#requestContext(entry)}>Read discussion →</button>` : nothing}${this.#context.value ? (entry.contexts ?? []).map(link => html `<button type="button" @click=${() => this.#requestContext({ ...entry, discussion: link.id })}>${link.label} →</button>`) : nothing}</div>
138
+ </article></li>`)}</ol>
139
+ ${!entries.length && !busy && !session.error.value ? html `<p class="ac-empty">No messages in this conversation.</p>` : nothing}
140
+ ${session.hasNewer.value ? html `<div class="ac-edge"><button type="button" ?disabled=${!!busy} @click=${() => this.#newer()}>Load newer messages</button></div>` : nothing}
141
+ </div>
142
+ <div class="ac-feedback"><aster-progress-status .busy=${!!busy} .error=${session.error.value} .pendingText=${busy === "older" ? "Loading earlier history…" : "Loading conversation…"} .completeText=${"History updated"} .reserveSpace=${true}></aster-progress-status>${session.error.value ? html `<button type="button" @click=${() => session.retry(this.#protectedIds())}>Retry history</button>` : nothing}${this.#arrivals.value ? html `<button type="button" @click=${() => this.#latest()}>${this.#arrivals.value} new ${this.#arrivals.value === 1 ? "message" : "messages"} · View latest ↓</button>` : nothing}${this.#selection.value && this.#passages.value ? html `<div class="ac-selection"><span>Text selected</span><button type="button" aria-haspopup="menu" @click=${(event) => this.#showSelectionMenu(event.currentTarget)}>Selection actions</button><button type="button" aria-label="Dismiss passage action" @click=${() => { this.#selection.value = undefined; }}>×</button></div>` : nothing}</div>
143
+ ${this.#readingOnly.value ? nothing : html `<form class="ac-composer" @submit=${(event) => { event.preventDefault(); void this.#send(); }}><label>Reply to ${session.title}<textarea aria-label=${`Reply to ${session.title}`} rows="2" .value=${session.draft.value} @input=${(event) => { session.draft.value = event.target.value; }}></textarea></label><footer><span>Your draft stays with this conversation</span><button type="submit" ?disabled=${!session.adapter.send || session.sending.value || !session.draft.value.trim()}>${session.sending.value ? "Sending…" : "Send reply ↗"}</button></footer>${session.sendError.value ? html `<p role="alert" class="ac-error">${session.sendError.value} Your draft is retained.</p>` : nothing}</form>`}
144
+ <span class="ac-sr" role="status" aria-live="polite">${this.#status.value}</span><aster-menu class="ac-selection-menu"></aster-menu>`;
145
+ }
146
+ updated() {
147
+ this.#scroller = this.querySelector(".ac-scroll") ?? undefined;
148
+ const session = this.#session.peek();
149
+ if (!session)
150
+ return;
151
+ if (session !== this.#current) {
152
+ this.#current = session;
153
+ this.#previous = [];
154
+ this.#anchor = undefined;
155
+ this.#following.value = true;
156
+ this.#arrivals.value = 0;
157
+ }
158
+ const entries = session.entries.peek();
159
+ if (entries !== this.#previous) {
160
+ const previousIds = new Set(this.#previous.map(entry => entry.id));
161
+ const lastOrder = this.#previous.at(-1)?.order;
162
+ const arrivals = lastOrder === undefined ? 0 : entries.filter(entry => !previousIds.has(entry.id) && entry.order > lastOrder).length;
163
+ if (arrivals && !this.#following.peek() && session.updateKind === "receive") {
164
+ this.#arrivals.value += arrivals;
165
+ this.#status.value = "New activity is available. Your reading position is unchanged.";
166
+ }
167
+ this.#previous = entries;
168
+ }
169
+ this.#ids = entries.map(entry => entry.id);
170
+ this.#restore();
171
+ }
172
+ #article(id) { return [...this.querySelectorAll("article[data-entry]")].find(article => article.dataset["entry"] === id); }
173
+ #capture() {
174
+ const scroller = this.#scroller;
175
+ if (!scroller || !scroller.clientHeight)
176
+ return;
177
+ const top = scroller.getBoundingClientRect().top;
178
+ const article = [...this.querySelectorAll("article[data-entry]")].find(item => item.getBoundingClientRect().bottom > top + 2);
179
+ if (article)
180
+ this.#anchor = { id: article.dataset["entry"], offset: article.getBoundingClientRect().top - top, index: this.#ids.indexOf(article.dataset["entry"]) };
181
+ }
182
+ #restore() {
183
+ const scroller = this.#scroller;
184
+ if (!scroller?.clientHeight)
185
+ return;
186
+ if (this.#following.peek())
187
+ this.#setScroll(scroller.scrollHeight);
188
+ else if (this.#anchor) {
189
+ let article = this.#article(this.#anchor.id);
190
+ if (!article) {
191
+ const lostFocus = this.#focusedId === this.#anchor.id;
192
+ article = this.#article(this.#ids[Math.min(this.#anchor.index, this.#ids.length - 1)] ?? "");
193
+ if (article) {
194
+ this.#anchor = { ...this.#anchor, id: article.dataset["entry"] };
195
+ this.#status.value = "The previous message is unavailable. Showing a nearby message.";
196
+ if (lostFocus)
197
+ article.focus({ preventScroll: true });
198
+ }
199
+ }
200
+ if (article)
201
+ this.#setScroll(scroller.scrollTop + article.getBoundingClientRect().top - scroller.getBoundingClientRect().top - this.#anchor.offset);
202
+ }
203
+ this.#capture();
204
+ }
205
+ #setScroll(top) {
206
+ if (!this.#scroller)
207
+ return;
208
+ this.#scroller.scrollTop = top;
209
+ this.#programmatic = this.#scroller.scrollTop;
210
+ }
211
+ #scroll() {
212
+ const scroller = this.#scroller;
213
+ if (!scroller)
214
+ return;
215
+ if (this.#programmatic !== undefined && Math.abs(scroller.scrollTop - this.#programmatic) < 1) {
216
+ this.#programmatic = undefined;
217
+ return;
218
+ }
219
+ this.#programmatic = undefined;
220
+ this.#following.value = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 24 && !this.#session.peek()?.hasNewer.peek();
221
+ if (this.#following.peek())
222
+ this.#arrivals.value = 0;
223
+ this.#capture();
224
+ if (this.#auto.peek() && scroller.scrollTop < 60)
225
+ void this.#older();
226
+ }
227
+ #protectedIds() {
228
+ const ids = new Set();
229
+ if (this.#anchor)
230
+ ids.add(this.#anchor.id);
231
+ const active = document.activeElement?.closest("article[data-entry]");
232
+ if (active && this.contains(active))
233
+ ids.add(active.dataset["entry"]);
234
+ const selection = getSelection();
235
+ if (selection?.rangeCount && !selection.isCollapsed) {
236
+ const range = selection.getRangeAt(0);
237
+ for (const article of this.querySelectorAll("article[data-entry]"))
238
+ if (range.intersectsNode(article))
239
+ ids.add(article.dataset["entry"]);
240
+ }
241
+ return [...ids];
242
+ }
243
+ async #older() {
244
+ const session = this.#session.peek();
245
+ if (!session?.hasOlder.peek() || session.busy.peek())
246
+ return;
247
+ this.#following.value = false;
248
+ this.#capture();
249
+ await session.loadOlder(this.#protectedIds());
250
+ }
251
+ async #newer() { this.#capture(); await this.#session.peek()?.loadNewer(this.#protectedIds()); }
252
+ async #latest() {
253
+ const session = this.#session.peek();
254
+ if (!session)
255
+ return;
256
+ const ok = await session.open();
257
+ if (ok && this.isConnected) {
258
+ this.#following.value = true;
259
+ this.#arrivals.value = 0;
260
+ this.#restore();
261
+ }
262
+ }
263
+ async #send() {
264
+ const session = this.#session.peek();
265
+ if (session && await session.send(() => this.#protectedIds())) {
266
+ this.#status.value = "Reply sent.";
267
+ if (session.hasNewer.peek())
268
+ this.#arrivals.value++;
269
+ }
270
+ }
271
+ #step(direction) {
272
+ const active = document.activeElement?.closest("article[data-entry]")?.dataset["entry"] ?? this.#selectedTarget ?? this.#anchor?.id;
273
+ const index = active ? this.#ids.indexOf(active) : -1;
274
+ const id = this.#ids[Math.max(0, Math.min(this.#ids.length - 1, index + direction))];
275
+ if (id) {
276
+ this.#selectedTarget = id;
277
+ this.reveal(id);
278
+ }
279
+ }
280
+ #keys(event) {
281
+ if (event.target.closest("textarea,input,select,[contenteditable]"))
282
+ return;
283
+ if (event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
284
+ event.preventDefault();
285
+ this.#step(event.key === "ArrowUp" ? -1 : 1);
286
+ }
287
+ if ((event.shiftKey && event.key === "F10") || event.key === "ContextMenu") {
288
+ this.#readSelection();
289
+ const body = getSelection()?.anchorNode?.parentElement?.closest("[data-passage-body]");
290
+ if (this.#selection.peek() && body) {
291
+ event.preventDefault();
292
+ this.#showSelectionMenu(body);
293
+ }
294
+ }
295
+ if (event.key === "Escape")
296
+ this.#selection.value = undefined;
297
+ }
298
+ #requestContext(entry) { this.#capture(); this.emit("conversation-context", { entry, scope: this.#session.peek().scope, pane: this }); }
299
+ #requestPassage(passage) { this.#capture(); this.#selection.value = undefined; this.emit("conversation-passage", { passage, pane: this }); }
300
+ #readSelection() {
301
+ const selected = getSelection();
302
+ if (selected?.rangeCount && !selected.isCollapsed && this.contains(selected.anchorNode) && this.contains(selected.focusNode)
303
+ && selected.anchorNode?.parentElement?.closest(".ac-scroll") && this.#following.peek()) {
304
+ this.#capture();
305
+ this.#following.value = false;
306
+ }
307
+ if (!this.#passages.peek() || this.#renderer.peek())
308
+ return;
309
+ if (this.querySelector(".ac-selection")?.contains(document.activeElement) || this.querySelector("aster-menu")?.contains(document.activeElement))
310
+ return;
311
+ const selection = getSelection();
312
+ const element = (node) => node instanceof Element ? node : node?.parentElement;
313
+ const start = element(selection?.anchorNode)?.closest("[data-passage-body]");
314
+ const end = element(selection?.focusNode)?.closest("[data-passage-body]");
315
+ if (!selection?.rangeCount || selection.isCollapsed || !start || start !== end || !this.contains(start) || !selection.toString().trim()) {
316
+ this.#selection.value = undefined;
317
+ return;
318
+ }
319
+ const entry = this.#session.peek()?.entries.peek().find(item => item.id === start.closest("[data-entry]")?.dataset["entry"]);
320
+ if (!entry || entry.kind === "event" || start.textContent !== entry.text)
321
+ return;
322
+ const range = selection.getRangeAt(0), prefix = range.cloneRange();
323
+ prefix.selectNodeContents(start);
324
+ prefix.setEnd(range.startContainer, range.startOffset);
325
+ const offset = prefix.toString().length;
326
+ if (!this.#selection.peek()) {
327
+ this.#capture();
328
+ this.#following.value = false;
329
+ }
330
+ this.#selectionRange = range.cloneRange();
331
+ this.#selection.value = { scope: this.#session.peek().scope, entry, exact: range.toString(), start: offset, end: offset + range.toString().length };
332
+ }
333
+ #showSelectionMenu(anchor) {
334
+ const passage = this.#selection.peek(), menu = this.querySelector("aster-menu"), range = this.#selectionRange;
335
+ if (!passage || !menu)
336
+ return;
337
+ const restore = () => { if (range?.startContainer.isConnected) {
338
+ const selection = getSelection();
339
+ selection?.removeAllRanges();
340
+ selection?.addRange(range);
341
+ } };
342
+ const items = [
343
+ { label: "Copy", run: () => { restore(); void this.#copyText(passage.exact, "Selected text copied."); } },
344
+ { label: "Discuss passage…", run: () => { restore(); this.#requestPassage(passage); } },
345
+ ];
346
+ const opener = this.#article(passage.entry.id) ?? this.#scroller ?? null;
347
+ if (anchor instanceof HTMLElement)
348
+ menu.openFrom(anchor, items, "Selected text", opener ?? anchor);
349
+ else
350
+ menu.openAt(anchor, items, "Selected text", opener);
351
+ }
352
+ async #copyText(text, confirmation) {
353
+ try {
354
+ if (!navigator.clipboard)
355
+ throw new Error("Clipboard unavailable");
356
+ await navigator.clipboard.writeText(text);
357
+ this.#status.value = confirmation;
358
+ }
359
+ catch {
360
+ this.#status.value = "Copy unavailable. Select the message text and use your browser’s copy shortcut.";
361
+ }
362
+ }
363
+ }
364
+ define("aster-conversation-pane", ConversationPane);
365
+ /** Root session is borrowed. Context sessions created through providers are owned. */
366
+ export class ConversationReader extends AsterElement {
367
+ static tag = "aster-conversation-reader";
368
+ static contract = Object.freeze({
369
+ session: t.optional(t.instance(ConversationSession)),
370
+ contextProvider: t.optional(t.custom("conversation context provider", (value) => typeof value === "function", { asterType: "function" })),
371
+ passageProvider: t.optional(t.custom("passage discussion provider", (value) => typeof value === "function", { asterType: "function" })),
372
+ entryRenderer: t.optional(t.custom("conversation entry renderer", (value) => typeof value === "function", { asterType: "function" })),
373
+ autoLoadHistory: t.optional(t.boolean),
374
+ focusModeEnabled: t.optional(t.boolean),
375
+ readerActions: t.optional(t.custom("focused reader command provider", (value) => typeof value === "function", { asterType: "function" })),
376
+ });
377
+ #session = this.prop(undefined);
378
+ #context = this.prop(undefined);
379
+ #passage = this.prop(undefined);
380
+ #renderer = this.prop(undefined);
381
+ #auto = this.prop(true);
382
+ #contexts = this.prop([]);
383
+ #trail = this.prop([]);
384
+ #busy = this.prop(false);
385
+ #error = this.prop(undefined);
386
+ #focusEnabled = this.prop(true);
387
+ #readerActions = this.prop(undefined);
388
+ #readerSession = this.prop(undefined);
389
+ #readerOpen = this.prop(false);
390
+ #readerTitleId = `ac-reader-${crypto.randomUUID()}`;
391
+ #readerLocations = new Map();
392
+ #readerOrigin;
393
+ #focusGeneration = 0;
394
+ #request;
395
+ #generation = 0;
396
+ #root;
397
+ #returnFocus = [];
398
+ get session() { return this.#session.value; }
399
+ set session(value) { if (this.#session.peek() !== value)
400
+ this.#reset(); this.#session.value = value; }
401
+ get contextProvider() { return this.#context.value; }
402
+ set contextProvider(value) { this.#context.value = value; }
403
+ get passageProvider() { return this.#passage.value; }
404
+ set passageProvider(value) { this.#passage.value = value; }
405
+ get entryRenderer() { return this.#renderer.value; }
406
+ set entryRenderer(value) { this.#renderer.value = value; }
407
+ get autoLoadHistory() { return this.#auto.value; }
408
+ set autoLoadHistory(value) { this.#auto.value = value; }
409
+ get focusModeEnabled() { return this.#focusEnabled.value; }
410
+ set focusModeEnabled(value) { this.#focusEnabled.value = value; if (!value)
411
+ this.closeReader(); }
412
+ get readerActions() { return this.#readerActions.value; }
413
+ set readerActions(value) { this.#readerActions.value = value; }
414
+ get contextCount() { return this.#contexts.peek().length; }
415
+ /** Routes confirmed notifications through the viewport's focus/selection protection. */
416
+ receive(scope, entry) {
417
+ const panes = [...this.querySelectorAll("aster-conversation-pane")];
418
+ const pane = (this.#readerOpen.peek() ? panes.find(item => item.classList.contains("ac-reader-pane") && item.session?.scope === scope) : undefined) ?? panes.find(item => item.session?.scope === scope);
419
+ return pane?.receive(entry) ?? false;
420
+ }
421
+ connectedCallback() {
422
+ super.connectedCallback();
423
+ const context = (event) => {
424
+ const { entry, scope, pane } = event.detail;
425
+ const provider = this.#context.peek();
426
+ if (provider)
427
+ void this.#open(signal => provider(entry, scope, signal), pane, entry.id);
428
+ };
429
+ const passage = (event) => {
430
+ const { passage, pane } = event.detail;
431
+ const provider = this.#passage.peek();
432
+ if (provider)
433
+ void this.#open(signal => provider(passage, signal), pane, passage.entry.id, true);
434
+ };
435
+ this.addEventListener("conversation-context", context);
436
+ this.addEventListener("conversation-passage", passage);
437
+ const focus = (event) => { const { pane, opener } = event.detail; this.showReader(pane.session?.scope, opener); };
438
+ this.addEventListener("conversation-focus", focus);
439
+ this.onDisconnect(() => { this.removeEventListener("conversation-context", context); this.removeEventListener("conversation-passage", passage); this.removeEventListener("conversation-focus", focus); this.#reset(); });
440
+ }
441
+ /** Opens a reading-only view of the active discussion or an explicitly retained scope. */
442
+ showReader(scope = this.#trail.peek().at(-1) ?? this.#session.peek()?.scope, opener = document.activeElement) {
443
+ if (!this.#focusEnabled.peek() || !scope || this.#readerOpen.peek())
444
+ return;
445
+ const pane = [...this.querySelectorAll("aster-conversation-pane:not(.ac-reader-pane)")].find(item => item.session?.scope === scope);
446
+ if (!pane?.session)
447
+ throw new Error("Open the conversation before opening its focused reader.");
448
+ this.#readerOrigin = { pane, location: pane.location(), opener };
449
+ this.#readerSession.value = pane.session;
450
+ this.#readerOpen.value = true;
451
+ const generation = ++this.#focusGeneration;
452
+ queueMicrotask(() => {
453
+ const dialog = this.querySelector(".ac-focus-dialog");
454
+ if (!dialog || !this.isConnected || generation !== this.#focusGeneration)
455
+ return;
456
+ dialog.showModal();
457
+ const reader = this.querySelector(".ac-reader-pane");
458
+ const location = this.#readerLocations.get(scope) ?? this.#readerOrigin.location;
459
+ void reader.restoreLocation(location).then(() => { if (generation === this.#focusGeneration && this.#readerOpen.peek())
460
+ reader.focusReading(); });
461
+ });
462
+ }
463
+ closeReader() {
464
+ const dialog = this.querySelector(".ac-focus-dialog");
465
+ if (dialog?.open)
466
+ dialog.close();
467
+ else if (this.#readerOpen.peek()) {
468
+ this.#readerOpen.value = false;
469
+ this.#focusGeneration++;
470
+ this.#readerOrigin = undefined;
471
+ }
472
+ }
473
+ async #readerClosed() {
474
+ const session = this.#readerSession.peek(), pane = this.querySelector(".ac-reader-pane");
475
+ if (session && pane)
476
+ this.#readerLocations.set(session.scope, pane.location());
477
+ const origin = this.#readerOrigin;
478
+ this.#readerOrigin = undefined;
479
+ this.#readerOpen.value = false;
480
+ const generation = ++this.#focusGeneration;
481
+ if (!origin)
482
+ return;
483
+ await origin.pane.restoreLocation(origin.location);
484
+ if (generation !== this.#focusGeneration)
485
+ return;
486
+ if (origin.opener?.isConnected)
487
+ origin.opener.focus({ preventScroll: true });
488
+ else
489
+ origin.pane.focusReading();
490
+ }
491
+ returnToSource() {
492
+ this.#request?.abort();
493
+ this.#generation++;
494
+ this.#busy.value = false;
495
+ this.#trail.value = this.#trail.peek().slice(0, -1);
496
+ const source = this.#returnFocus.pop();
497
+ requestAnimationFrame(() => { if (source?.pane.isConnected)
498
+ source.pane.focusSource(source.entryId); });
499
+ }
500
+ render() {
501
+ const trail = this.#trail.value, active = trail.at(-1), session = this.#session.value;
502
+ const focused = this.#readerSession.value;
503
+ const pane = (value, root) => html `<aster-conversation-pane class=${root ? "ac-main" : "ac-context-pane"} ?hidden=${!root && active !== value.scope} .session=${value} .entryRenderer=${this.#renderer.value} .autoLoadHistory=${this.#auto.value} .contextEnabled=${!!this.#context.value} .passagesEnabled=${!!this.#passage.value} .focusEnabled=${this.#focusEnabled.value}></aster-conversation-pane>`;
504
+ return html `<div class="ac-reader" ?data-context=${!!active}>
505
+ ${session ? pane(session, true) : nothing}
506
+ <section class="ac-context" ?hidden=${!active} aria-label="Focused discussion"><div class="ac-return"><button type="button" @click=${() => this.returnToSource()}>← ${trail.length > 1 ? "Return to source discussion" : "Return to reading position"}</button><button type="button" aria-label="Return from discussion" @click=${() => this.returnToSource()}>×</button></div>${each(this.#contexts.value, item => item.key, item => pane(item.session, false))}</section>
507
+ </div><div class="ac-reader-feedback"><aster-progress-status .busy=${this.#busy.value} .error=${this.#error.value} .pendingText=${"Opening discussion…"} .completeText=${"Discussion opened"} .reserveSpace=${false}></aster-progress-status></div>
508
+ <dialog class="ac-focus-dialog" aria-labelledby=${this.#readerTitleId} @close=${() => void this.#readerClosed()}><header class="ac-focus-toolbar"><div><p class="ac-kicker">Focused reading</p><h2 id=${this.#readerTitleId}>${focused?.title ?? "Conversation"}</h2></div><div class="ac-focus-actions"><aster-command-bar .commands=${focused ? this.#readerActions.value?.(focused) ?? [] : []} .label=${"Reader actions"}></aster-command-bar><button type="button" class="ac-reader-close" aria-label="Close focused reader" title="Close focused reader (Escape)" @click=${() => this.closeReader()}>×</button></div></header><div class="ac-focus-body">${focused ? html `<aster-conversation-pane class="ac-reader-pane" .session=${focused} .entryRenderer=${this.#renderer.value} .autoLoadHistory=${this.#auto.value} .readingOnly=${true}></aster-conversation-pane>` : nothing}</div><footer class="ac-focus-footer">Escape to return · Your reply draft stays in the conversation</footer></dialog>`;
509
+ }
510
+ updated() {
511
+ if (this.#root !== this.#session.peek())
512
+ this.#root = this.#session.peek();
513
+ for (const pane of this.querySelectorAll("aster-conversation-pane"))
514
+ if (!pane.hidden)
515
+ pane.restore();
516
+ }
517
+ #reset() {
518
+ this.#readerOrigin = undefined;
519
+ this.#focusGeneration++;
520
+ this.closeReader();
521
+ this.#readerOpen.value = false;
522
+ this.#readerSession.value = undefined;
523
+ this.#readerLocations.clear();
524
+ this.#generation++;
525
+ this.#request?.abort();
526
+ this.#request = undefined;
527
+ this.#busy.value = false;
528
+ for (const context of this.#contexts.peek())
529
+ context.session.dispose();
530
+ this.#contexts.value = [];
531
+ this.#trail.value = [];
532
+ this.#returnFocus = [];
533
+ this.#error.value = undefined;
534
+ }
535
+ async #open(provider, pane, entryId, creates = false) {
536
+ this.#request?.abort();
537
+ const request = new AbortController(), generation = ++this.#generation;
538
+ this.#request = request;
539
+ this.#busy.value = true;
540
+ this.#error.value = undefined;
541
+ try {
542
+ if (creates && this.#contexts.peek().length >= 8)
543
+ throw new Error("Eight discussions are retained. Leave this workspace before creating another discussion.");
544
+ const options = await provider(request.signal);
545
+ if (!options || request.signal.aborted || generation !== this.#generation || !this.isConnected)
546
+ return;
547
+ if (options.scope === this.#session.peek()?.scope || this.#trail.peek().includes(options.scope))
548
+ throw new Error("This discussion is already open. Use Return to go back to it.");
549
+ let context = this.#contexts.peek().find(item => item.key === options.scope);
550
+ if (!context) {
551
+ if (this.#contexts.peek().length >= 8)
552
+ throw new Error("Eight discussions are retained. Reuse an open discussion or leave this workspace before opening more.");
553
+ context = { key: options.scope, session: new ConversationSession(options) };
554
+ this.#contexts.value = [...this.#contexts.peek(), context];
555
+ }
556
+ this.#returnFocus.push({ pane, entryId });
557
+ this.#trail.value = [...this.#trail.peek(), context.key];
558
+ if (!context.session.entries.peek().length)
559
+ void context.session.open();
560
+ requestAnimationFrame(() => { if (generation === this.#generation)
561
+ this.#activePane()?.focusReading(); });
562
+ }
563
+ catch (error) {
564
+ if (!request.signal.aborted && generation === this.#generation)
565
+ this.#error.value = error instanceof Error ? error.message : "The discussion could not be opened.";
566
+ }
567
+ finally {
568
+ if (generation === this.#generation) {
569
+ this.#busy.value = false;
570
+ this.#request = undefined;
571
+ }
572
+ }
573
+ }
574
+ #activePane() { return [...this.querySelectorAll("aster-conversation-pane")].find(pane => pane.session?.scope === this.#trail.peek().at(-1)); }
575
+ }
576
+ define("aster-conversation-reader", ConversationReader);
@@ -0,0 +1,34 @@
1
+ import { AsterElement, type View } from "@getaster/elements";
2
+ export interface HierarchyLevel {
3
+ readonly id: string;
4
+ readonly title: string;
5
+ readonly summary?: string;
6
+ readonly actions?: View;
7
+ readonly content: View;
8
+ }
9
+ /** Controlled navigation; hosts retain or dispose their own keyed sessions. */
10
+ export declare class ContextualHierarchy extends AsterElement {
11
+ #private;
12
+ static readonly tag = "aster-contextual-hierarchy";
13
+ static readonly contract: Readonly<{
14
+ levels: Omit<import("@getaster/elements").CompositeType<readonly HierarchyLevel[]>, "validate"> & import("@getaster/elements").CompositeType<readonly HierarchyLevel[] | undefined> & {
15
+ readonly optional: true;
16
+ };
17
+ navigationVisible: Omit<import("@getaster/elements").CompositeType<boolean>, "validate"> & import("@getaster/elements").CompositeType<boolean | undefined> & {
18
+ readonly optional: true;
19
+ };
20
+ motion: Omit<import("@getaster/elements").CompositeType<"none" | "slide">, "validate"> & import("@getaster/elements").CompositeType<"none" | "slide" | undefined> & {
21
+ readonly optional: true;
22
+ };
23
+ }>;
24
+ /** Pointer activation does not focus buttons in every browser; pass the actual opener. */
25
+ navigate(levels: readonly HierarchyLevel[], opener?: HTMLElement): void;
26
+ get levels(): readonly HierarchyLevel[];
27
+ get navigationVisible(): boolean;
28
+ set navigationVisible(v: boolean);
29
+ get motion(): "none" | "slide";
30
+ set motion(v: "none" | "slide");
31
+ set levels(v: readonly HierarchyLevel[]);
32
+ protected render(): View;
33
+ protected updated(): void;
34
+ }