@poodle64/librarian 2026.9.6 → 2026.9.7

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 (32) hide show
  1. package/README.md +128 -43
  2. package/dist/attachments.d.ts +30 -0
  3. package/dist/attachments.js +80 -0
  4. package/dist/citations.d.ts +63 -0
  5. package/dist/citations.js +89 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.js +38 -12
  8. package/dist/components/activity-group/activity-group.svelte +36 -44
  9. package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
  10. package/dist/components/agent-transcript/agent-transcript.svelte +166 -26
  11. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
  12. package/dist/components/composer/composer.svelte +206 -53
  13. package/dist/components/composer/composer.svelte.d.ts +5 -1
  14. package/dist/components/conversation/conversation.svelte +179 -0
  15. package/dist/components/conversation/conversation.svelte.d.ts +26 -0
  16. package/dist/components/conversation/index.d.ts +2 -0
  17. package/dist/components/conversation/index.js +2 -0
  18. package/dist/components/document-pane/document-pane.svelte +177 -0
  19. package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
  20. package/dist/components/document-pane/index.d.ts +2 -0
  21. package/dist/components/document-pane/index.js +2 -0
  22. package/dist/components/markdown/index.d.ts +1 -1
  23. package/dist/components/markdown/index.js +1 -1
  24. package/dist/components/markdown/markdown.d.ts +13 -0
  25. package/dist/components/markdown/markdown.js +49 -1
  26. package/dist/components/markdown/markdown.svelte +85 -3
  27. package/dist/components/markdown/markdown.svelte.d.ts +6 -0
  28. package/dist/follow-scroll.svelte.d.ts +34 -0
  29. package/dist/follow-scroll.svelte.js +46 -0
  30. package/dist/transcript.svelte.d.ts +27 -0
  31. package/dist/transcript.svelte.js +27 -0
  32. package/package.json +16 -2
@@ -3,13 +3,17 @@ interface Props {
3
3
  value: string;
4
4
  running: boolean;
5
5
  scope: Scope;
6
+ /** Files to send with the next question. Cleared by the caller on send. */
7
+ files?: File[];
6
8
  /** Names for the two narrower scopes; absent means that scope is unavailable. */
7
9
  documentName?: string;
8
10
  collectionName?: string;
11
+ /** Hides the paperclip for a host whose ask route takes no files. */
12
+ attachments?: boolean;
9
13
  onscope: (scope: Scope) => void;
10
14
  onsubmit: () => void;
11
15
  onstop: () => void;
12
16
  }
13
- declare const Composer: import("svelte").Component<Props, {}, "value">;
17
+ declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
14
18
  type Composer = ReturnType<typeof Composer>;
15
19
  export default Composer;
@@ -0,0 +1,179 @@
1
+ <!--
2
+ The whole reading surface: every turn, the scroll that follows the stream,
3
+ and the source pane beside it.
4
+
5
+ This owns the SCROLL CONTAINER, which is the reason it exists rather than the
6
+ host composing `AgentTranscript` in a div of its own. Follow-scroll, the
7
+ jump-to-latest pill and a document pane that narrows the transcript instead
8
+ of covering it are all one question — where the overflow lives — and three
9
+ hosts answering it separately is three chances to answer it differently.
10
+ -->
11
+ <script lang="ts">
12
+ import type { Snippet } from 'svelte';
13
+ import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
14
+ import type { Turn } from '../../transcript.svelte';
15
+ import type { Citation, LoadDocument } from '../../citations';
16
+ import { FollowScroll } from '../../follow-scroll.svelte';
17
+ import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
18
+ import DocumentPane from '../document-pane/document-pane.svelte';
19
+
20
+ interface Props {
21
+ turns: Turn[];
22
+ running: boolean;
23
+ /** `Transcript.version` — bumped per event. Text grows in place, so a
24
+ * count of turns is not enough to keep the scroll following. */
25
+ version?: number;
26
+ welcome?: string;
27
+ /** Up to three, shown in the empty state as tappable pills. */
28
+ examples?: string[];
29
+ onexample?: (question: string) => void;
30
+ /** Re-asks the last question. Offered on the last answer only. */
31
+ onregenerate?: () => void;
32
+ /** Enables the source pane. Without it, chips render but do not open. */
33
+ loadDocument?: LoadDocument;
34
+ collectionNames?: Set<string>;
35
+ /** The composer, rendered INSIDE the transcript column so the source
36
+ * pane narrows it too — a composer the host places outside slides
37
+ * under the pane the moment one opens. */
38
+ composer?: Snippet;
39
+ }
40
+
41
+ let {
42
+ turns,
43
+ running,
44
+ version = 0,
45
+ welcome = 'Ask Milton a question about the library.',
46
+ examples = [],
47
+ onexample,
48
+ onregenerate,
49
+ loadDocument,
50
+ collectionNames = new Set(),
51
+ composer
52
+ }: Props = $props();
53
+
54
+ let viewport = $state<HTMLElement | null>(null);
55
+ let open = $state<Citation | null>(null);
56
+ const follow = new FollowScroll();
57
+
58
+ function metrics(el: HTMLElement) {
59
+ return {
60
+ scrollTop: el.scrollTop,
61
+ clientHeight: el.clientHeight,
62
+ scrollHeight: el.scrollHeight
63
+ };
64
+ }
65
+
66
+ // A new question re-pins: the reader asked for the thing about to arrive,
67
+ // even if they were reading something further up when they asked.
68
+ //
69
+ // Deliberately NOT `$state`: an effect that both reads and writes one piece
70
+ // of reactive state re-triggers itself, which Svelte stops with
71
+ // `effect_update_depth_exceeded` — measured, on this component. Nothing
72
+ // renders this, so a plain variable is both correct and enough.
73
+ let seen = 0;
74
+ $effect(() => {
75
+ if (turns.length > seen) follow.pin();
76
+ seen = turns.length;
77
+ });
78
+
79
+ $effect(() => {
80
+ // Both dependencies are deliberate: `version` for text growing inside an
81
+ // existing block, `turns.length` for a whole new turn.
82
+ void version;
83
+ void turns.length;
84
+ if (!viewport || !follow.following) return;
85
+ viewport.scrollTop = viewport.scrollHeight;
86
+ });
87
+
88
+ function scrolled() {
89
+ if (viewport) follow.measure(metrics(viewport));
90
+ }
91
+
92
+ function jump() {
93
+ if (!viewport) return;
94
+ follow.pin();
95
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' });
96
+ }
97
+
98
+ function cite(citation: Citation) {
99
+ // A citation derived from the prose has no id to read, so its chip is
100
+ // legible but inert rather than opening an empty pane.
101
+ if (loadDocument && citation.document_id) open = citation;
102
+ }
103
+ </script>
104
+
105
+ <div class="flex min-h-0 flex-1">
106
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col">
107
+ <!-- The pill is positioned against the SCROLL region, not the column: the
108
+ composer is in the column too, and a pill over the input is a pill in
109
+ the way. -->
110
+ <div class="relative flex min-h-0 flex-1 flex-col">
111
+ <div
112
+ bind:this={viewport}
113
+ onscroll={scrolled}
114
+ role="log"
115
+ aria-live="polite"
116
+ aria-label="Conversation with Milton"
117
+ class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
118
+ >
119
+ <div class="mx-auto flex w-full max-w-[46rem] flex-col gap-8 px-4 py-6">
120
+ {#if turns.length === 0}
121
+ <div class="flex flex-col items-start gap-4 py-8">
122
+ <p class="text-foreground max-w-[46ch] text-lg leading-snug">{welcome}</p>
123
+ {#if examples.length > 0}
124
+ <ul class="flex flex-col items-start gap-2">
125
+ {#each examples.slice(0, 3) as example (example)}
126
+ <li class="max-w-full">
127
+ <button
128
+ type="button"
129
+ onclick={() => onexample?.(example)}
130
+ class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
131
+ >
132
+ {example}
133
+ </button>
134
+ </li>
135
+ {/each}
136
+ </ul>
137
+ {/if}
138
+ </div>
139
+ {/if}
140
+
141
+ {#each turns as turn, index (turn.id)}
142
+ <AgentTranscript
143
+ question={turn.question}
144
+ blocks={turn.blocks}
145
+ outcome={turn.outcome}
146
+ running={running && index === turns.length - 1}
147
+ citations={turn.citations ?? []}
148
+ {collectionNames}
149
+ oncite={cite}
150
+ onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
151
+ />
152
+ {/each}
153
+ </div>
154
+ </div>
155
+
156
+ {#if !follow.following && turns.length > 0}
157
+ <button
158
+ type="button"
159
+ onclick={jump}
160
+ class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
161
+ >
162
+ <ArrowDownIcon class="size-3.5" />
163
+ Jump to latest
164
+ </button>
165
+ {/if}
166
+ </div>
167
+
168
+ {#if composer}
169
+ <div class="shrink-0 px-4 pb-3">
170
+ <div class="mx-auto w-full max-w-[46rem]">{@render composer()}</div>
171
+ </div>
172
+ {/if}
173
+
174
+ </div>
175
+
176
+ {#if open && loadDocument}
177
+ <DocumentPane citation={open} {loadDocument} onclose={() => (open = null)} />
178
+ {/if}
179
+ </div>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Turn } from '../../transcript.svelte';
3
+ import type { LoadDocument } from '../../citations';
4
+ interface Props {
5
+ turns: Turn[];
6
+ running: boolean;
7
+ /** `Transcript.version` — bumped per event. Text grows in place, so a
8
+ * count of turns is not enough to keep the scroll following. */
9
+ version?: number;
10
+ welcome?: string;
11
+ /** Up to three, shown in the empty state as tappable pills. */
12
+ examples?: string[];
13
+ onexample?: (question: string) => void;
14
+ /** Re-asks the last question. Offered on the last answer only. */
15
+ onregenerate?: () => void;
16
+ /** Enables the source pane. Without it, chips render but do not open. */
17
+ loadDocument?: LoadDocument;
18
+ collectionNames?: Set<string>;
19
+ /** The composer, rendered INSIDE the transcript column so the source
20
+ * pane narrows it too — a composer the host places outside slides
21
+ * under the pane the moment one opens. */
22
+ composer?: Snippet;
23
+ }
24
+ declare const Conversation: import("svelte").Component<Props, {}, "">;
25
+ type Conversation = ReturnType<typeof Conversation>;
26
+ export default Conversation;
@@ -0,0 +1,2 @@
1
+ export { default as Conversation } from './conversation.svelte';
2
+ export { default } from './conversation.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as Conversation } from './conversation.svelte';
2
+ export { default } from './conversation.svelte';
@@ -0,0 +1,177 @@
1
+ <!--
2
+ The cited document, open at the cited passage.
3
+
4
+ One element, two shapes. On a desktop it is a real column in the flow at
5
+ about 40% of the width and draggable — the transcript narrows beside it and
6
+ nothing is covered. Below `lg` there is no width to give it, so the same
7
+ element becomes a bottom sheet over the conversation. Two components would
8
+ have meant two behaviours to keep honest; a class list is cheaper than that.
9
+ -->
10
+ <script lang="ts">
11
+ import XIcon from '@lucide/svelte/icons/x';
12
+ import type { Citation, LoadDocument, LoadedDocument } from '../../citations';
13
+ import Markdown from '../markdown/markdown.svelte';
14
+
15
+ interface Props {
16
+ citation: Citation;
17
+ loadDocument: LoadDocument;
18
+ onclose: () => void;
19
+ }
20
+
21
+ let { citation, loadDocument, onclose }: Props = $props();
22
+
23
+ const MIN_WIDTH = 320;
24
+ /** ~40% of a 1440 desktop, which is the width the pane is designed at. */
25
+ const DEFAULT_WIDTH = 560;
26
+
27
+ let width = $state(DEFAULT_WIDTH);
28
+ let document_ = $state<LoadedDocument | null>(null);
29
+ let failed = $state(false);
30
+ let scroller = $state<HTMLElement | null>(null);
31
+ let closeButton = $state<HTMLButtonElement | null>(null);
32
+
33
+ // Keyed on the id, so re-citing the same document while the pane is open
34
+ // moves to the new section without a second fetch and without a flash of
35
+ // the loading state.
36
+ // Plain, not `$state`: the effect below both reads and writes it, and
37
+ // nothing renders it.
38
+ let loadedId: string | null = null;
39
+
40
+ $effect(() => {
41
+ const id = citation.document_id;
42
+ if (!id || id === loadedId) return;
43
+ let cancelled = false;
44
+ document_ = null;
45
+ failed = false;
46
+ loadDocument(id)
47
+ .then((doc) => {
48
+ if (cancelled) return;
49
+ document_ = doc;
50
+ loadedId = id;
51
+ })
52
+ .catch(() => {
53
+ if (!cancelled) failed = true;
54
+ });
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ });
59
+
60
+ // The anchor is the citation's own address; the section heading is the
61
+ // fallback for a citation that carries only prose-derived words.
62
+ const activeAnchor = $derived.by(() => {
63
+ const sections = document_?.sections ?? [];
64
+ if (citation.anchor && sections.some((s) => s.anchor === citation.anchor))
65
+ return citation.anchor;
66
+ return sections.find((s) => s.heading === citation.section)?.anchor ?? sections[0]?.anchor ?? '';
67
+ });
68
+
69
+ $effect(() => {
70
+ if (!scroller || !activeAnchor) return;
71
+ const target = scroller.querySelector(`[data-anchor="${CSS.escape(activeAnchor)}"]`);
72
+ target?.scrollIntoView({ block: 'center' });
73
+ });
74
+
75
+ // Opening a pane a reader reached with the keyboard must move focus into
76
+ // it, or Escape and Tab both act on the transcript behind it.
77
+ $effect(() => {
78
+ const returnTo = globalThis.document?.activeElement as HTMLElement | null;
79
+ closeButton?.focus();
80
+ return () => returnTo?.focus?.();
81
+ });
82
+
83
+ function keydown(event: KeyboardEvent) {
84
+ if (event.key === 'Escape') {
85
+ event.stopPropagation();
86
+ onclose();
87
+ }
88
+ }
89
+
90
+ let dragFrom: { x: number; width: number } | null = null;
91
+
92
+ function startDrag(event: PointerEvent) {
93
+ dragFrom = { x: event.clientX, width };
94
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
95
+ }
96
+
97
+ function drag(event: PointerEvent) {
98
+ if (!dragFrom) return;
99
+ // The handle is on the pane's LEFT edge, so dragging left widens it.
100
+ const next = dragFrom.width - (event.clientX - dragFrom.x);
101
+ width = Math.max(MIN_WIDTH, Math.min(next, window.innerWidth * 0.7));
102
+ }
103
+
104
+ function endDrag(event: PointerEvent) {
105
+ dragFrom = null;
106
+ (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
107
+ }
108
+ </script>
109
+
110
+ <svelte:window onkeydown={keydown} />
111
+
112
+ <aside
113
+ style="--pane-width: {width}px"
114
+ aria-label="Source document"
115
+ class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:static lg:h-auto lg:w-[var(--pane-width)] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
116
+ >
117
+ <!-- svelte-ignore a11y_no_static_element_interactions -- a pointer-only
118
+ affordance for a width that has a keyboard-independent default; the
119
+ pane is fully usable without ever touching it. -->
120
+ <div
121
+ onpointerdown={startDrag}
122
+ onpointermove={drag}
123
+ onpointerup={endDrag}
124
+ class="hover:bg-primary/40 absolute inset-y-0 left-0 hidden w-1.5 cursor-col-resize lg:block"
125
+ ></div>
126
+
127
+ <!-- The sheet's grabber. Below `lg` this is an overlay a reader has to be
128
+ able to see the top edge of; on a desktop it is a column, and a column
129
+ with a handle on it reads as draggable in the wrong axis. -->
130
+ <div class="flex justify-center pt-2 pb-1 lg:hidden" aria-hidden="true">
131
+ <span class="bg-border h-1 w-9 rounded-full"></span>
132
+ </div>
133
+
134
+ <header class="border-border flex items-start gap-2 border-b px-4 py-3 lg:pt-3">
135
+ <div class="min-w-0 flex-1">
136
+ <h2 class="text-foreground truncate text-sm font-semibold">
137
+ {document_?.title ?? citation.title}
138
+ </h2>
139
+ {#if citation.section}
140
+ <p class="text-muted-foreground truncate text-xs">{citation.section}</p>
141
+ {/if}
142
+ </div>
143
+ <button
144
+ bind:this={closeButton}
145
+ type="button"
146
+ onclick={onclose}
147
+ aria-label="Close source"
148
+ class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring -mt-1 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
149
+ >
150
+ <XIcon class="size-4" />
151
+ </button>
152
+ </header>
153
+
154
+ <div bind:this={scroller} class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
155
+ {#if failed}
156
+ <p class="text-muted-foreground text-sm">That document can't be opened right now.</p>
157
+ {:else if !document_}
158
+ <div class="flex flex-col gap-2" aria-hidden="true">
159
+ {#each [0, 1, 2, 3] as row (row)}
160
+ <div class="bg-muted h-4 animate-pulse rounded" style="width: {90 - row * 12}%"></div>
161
+ {/each}
162
+ </div>
163
+ {:else}
164
+ {#each document_.sections as section (section.anchor)}
165
+ <section
166
+ data-anchor={section.anchor}
167
+ class="scroll-mt-4 rounded-lg px-3 py-2 {section.anchor === activeAnchor
168
+ ? 'border-primary/50 bg-primary/8 border-l-2'
169
+ : ''}"
170
+ >
171
+ <h3 class="text-foreground mb-1 text-sm font-semibold">{section.heading}</h3>
172
+ <Markdown content={section.text} dense />
173
+ </section>
174
+ {/each}
175
+ {/if}
176
+ </div>
177
+ </aside>
@@ -0,0 +1,9 @@
1
+ import type { Citation, LoadDocument } from '../../citations';
2
+ interface Props {
3
+ citation: Citation;
4
+ loadDocument: LoadDocument;
5
+ onclose: () => void;
6
+ }
7
+ declare const DocumentPane: import("svelte").Component<Props, {}, "">;
8
+ type DocumentPane = ReturnType<typeof DocumentPane>;
9
+ export default DocumentPane;
@@ -0,0 +1,2 @@
1
+ export { default as DocumentPane } from './document-pane.svelte';
2
+ export { default } from './document-pane.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as DocumentPane } from './document-pane.svelte';
2
+ export { default } from './document-pane.svelte';
@@ -1,3 +1,3 @@
1
1
  export { default as Markdown } from './markdown.svelte';
2
2
  export { default } from './markdown.svelte';
3
- export { balance, render, markCollections, highlight } from './markdown';
3
+ export { balance, render, markCollections, markCitations, highlight } from './markdown';
@@ -1,3 +1,3 @@
1
1
  export { default as Markdown } from './markdown.svelte';
2
2
  export { default } from './markdown.svelte';
3
- export { balance, render, markCollections, highlight } from './markdown';
3
+ export { balance, render, markCollections, markCitations, highlight } from './markdown';
@@ -29,6 +29,19 @@ export declare function render(markdown: string, { streaming }?: {
29
29
  * cannot drift out of sync with the rendering.
30
30
  */
31
31
  export declare function markCollections(root: HTMLElement, collections: Set<string>): void;
32
+ /**
33
+ * Turn every inline `[n]` marker into a citation chip.
34
+ *
35
+ * Done against the rendered DOM rather than the markdown string, for the same
36
+ * reason `markCollections` is: a `[3]` inside a fenced code block or a link
37
+ * label is not a citation, and `closest()` settles that in one call where a
38
+ * string-level regex would need to re-implement the parser to know.
39
+ *
40
+ * `sup` rather than `button`: the sanitiser strips `button` (it is in
41
+ * `FORBID_TAGS`, and rightly — this is model output), so the chip carries the
42
+ * button ROLE and a tab stop, and the component delegates the events.
43
+ */
44
+ export declare function markCitations(root: HTMLElement, valid: Set<number>): void;
32
45
  /**
33
46
  * Replace every `<pre><code>` in a rendered fragment with a highlighted one.
34
47
  * Runs against the DOM node rather than the HTML string so it can be applied
@@ -39,7 +39,7 @@ export function render(markdown, { streaming = false } = {}) {
39
39
  const source = streaming ? balance(markdown) : markdown;
40
40
  const html = marked.parse(source, { async: false });
41
41
  return DOMPurify.sanitize(html, {
42
- ADD_ATTR: ['target', 'rel'],
42
+ ADD_ATTR: ['target', 'rel', 'data-cite', 'tabindex', 'role'],
43
43
  FORBID_TAGS: ['style', 'form', 'input', 'button'],
44
44
  FORBID_ATTR: ['style', 'onerror', 'onload']
45
45
  });
@@ -89,6 +89,54 @@ export function markCollections(root, collections) {
89
89
  code.dataset.collection = 'true';
90
90
  }
91
91
  }
92
+ /**
93
+ * Turn every inline `[n]` marker into a citation chip.
94
+ *
95
+ * Done against the rendered DOM rather than the markdown string, for the same
96
+ * reason `markCollections` is: a `[3]` inside a fenced code block or a link
97
+ * label is not a citation, and `closest()` settles that in one call where a
98
+ * string-level regex would need to re-implement the parser to know.
99
+ *
100
+ * `sup` rather than `button`: the sanitiser strips `button` (it is in
101
+ * `FORBID_TAGS`, and rightly — this is model output), so the chip carries the
102
+ * button ROLE and a tab stop, and the component delegates the events.
103
+ */
104
+ export function markCitations(root, valid) {
105
+ if (valid.size === 0)
106
+ return;
107
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
108
+ const targets = [];
109
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
110
+ const text = node;
111
+ if (text.parentElement?.closest('pre, code, a, sup'))
112
+ continue;
113
+ if (/\[\d{1,3}\]/.test(text.data))
114
+ targets.push(text);
115
+ }
116
+ for (const text of targets) {
117
+ const fragment = document.createDocumentFragment();
118
+ let cursor = 0;
119
+ for (const match of text.data.matchAll(/\[(\d{1,3})\]/g)) {
120
+ const n = Number(match[1]);
121
+ if (!valid.has(n) || match.index === undefined)
122
+ continue;
123
+ fragment.append(text.data.slice(cursor, match.index));
124
+ const chip = document.createElement('sup');
125
+ chip.className = 'ds-cite';
126
+ chip.dataset.cite = String(n);
127
+ chip.setAttribute('role', 'button');
128
+ chip.setAttribute('tabindex', '0');
129
+ chip.setAttribute('aria-label', `Source ${n}`);
130
+ chip.textContent = String(n);
131
+ fragment.append(chip);
132
+ cursor = match.index + match[0].length;
133
+ }
134
+ if (cursor === 0)
135
+ continue;
136
+ fragment.append(text.data.slice(cursor));
137
+ text.replaceWith(fragment);
138
+ }
139
+ }
92
140
  /**
93
141
  * Replace every `<pre><code>` in a rendered fragment with a highlighted one.
94
142
  * Runs against the DOM node rather than the HTML string so it can be applied
@@ -7,16 +7,29 @@
7
7
  consuming app.
8
8
  -->
9
9
  <script lang="ts">
10
- import { highlight, markCollections, render } from './markdown';
10
+ import { highlight, markCitations, markCollections, render } from './markdown';
11
11
 
12
12
  interface Props {
13
13
  content: string;
14
14
  streaming?: boolean;
15
15
  /** Collection names to chip in backticks; omit if the caller has none. */
16
16
  collectionNames?: Set<string>;
17
+ /** `[n]` markers to turn into chips; omit and the markers stay as text. */
18
+ citationNumbers?: Set<number>;
19
+ oncite?: (n: number) => void;
20
+ /** A step down, for prose that is reference material beside an answer
21
+ * rather than the answer itself — the source pane's sections. */
22
+ dense?: boolean;
17
23
  }
18
24
 
19
- let { content, streaming = false, collectionNames = new Set() }: Props = $props();
25
+ let {
26
+ content,
27
+ streaming = false,
28
+ collectionNames = new Set(),
29
+ citationNumbers = new Set(),
30
+ oncite,
31
+ dense = false
32
+ }: Props = $props();
20
33
  let host = $state<HTMLElement | null>(null);
21
34
 
22
35
  const html = $derived(render(content, { streaming }));
@@ -27,12 +40,42 @@
27
40
  if (!host || !html) return;
28
41
  // Chips are cheap and wanted DURING streaming; highlighting is not.
29
42
  markCollections(host, collectionNames);
43
+ markCitations(host, citationNumbers);
30
44
  if (streaming) return;
31
45
  void highlight(host);
32
46
  });
47
+
48
+ // Delegated, because the chips are created by the sanitiser's output rather
49
+ // than by this template — there is no element here to put a handler on.
50
+ function citeFrom(target: EventTarget | null): number | null {
51
+ const chip = (target as HTMLElement | null)?.closest?.('[data-cite]');
52
+ const n = Number((chip as HTMLElement | undefined)?.dataset.cite);
53
+ return Number.isFinite(n) && n > 0 ? n : null;
54
+ }
55
+
56
+ function click(event: MouseEvent) {
57
+ const n = citeFrom(event.target);
58
+ if (n !== null) oncite?.(n);
59
+ }
60
+
61
+ function keydown(event: KeyboardEvent) {
62
+ if (event.key !== 'Enter' && event.key !== ' ') return;
63
+ const n = citeFrom(event.target);
64
+ if (n === null) return;
65
+ event.preventDefault();
66
+ oncite?.(n);
67
+ }
33
68
  </script>
34
69
 
35
- <div bind:this={host} class="agent-prose text-foreground text-base leading-7">
70
+ <!-- svelte-ignore a11y_no_static_element_interactions -- the interactive
71
+ elements are the sanitiser-created chips, which carry role and tabindex;
72
+ this element only delegates their events. -->
73
+ <div
74
+ bind:this={host}
75
+ onclick={click}
76
+ onkeydown={keydown}
77
+ class="agent-prose text-foreground {dense ? 'text-sm leading-6' : 'text-base leading-7'}"
78
+ >
36
79
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in render() -->
37
80
  {@html html}
38
81
  </div>
@@ -169,6 +212,10 @@
169
212
  border-radius: var(--radius);
170
213
  padding: 0.85em 1em;
171
214
  overflow-x: auto;
215
+ /* A fenced block is the one thing here allowed to be wider than the
216
+ measure, so it must scroll INSIDE itself: without this a 90-column
217
+ line makes the whole transcript scroll sideways at 390px. */
218
+ max-width: 100%;
172
219
  }
173
220
 
174
221
  .agent-prose :global(pre code) {
@@ -190,8 +237,11 @@
190
237
  color: var(--shiki-dark);
191
238
  }
192
239
 
240
+ /* `display: block` is what makes the overflow scroll: a real `table` box
241
+ ignores overflow-x and widens its container instead. */
193
242
  .agent-prose :global(table) {
194
243
  width: 100%;
244
+ max-width: 100%;
195
245
  border-collapse: collapse;
196
246
  font-size: 0.9em;
197
247
  display: block;
@@ -203,6 +253,9 @@
203
253
  border: 1px solid var(--border);
204
254
  padding: 0.4em 0.6em;
205
255
  text-align: start;
256
+ /* A cell must not be squeezed to one character per line by a narrow
257
+ viewport; the table scrolls instead. */
258
+ white-space: nowrap;
206
259
  }
207
260
 
208
261
  .agent-prose :global(th) {
@@ -220,6 +273,35 @@
220
273
  font-weight: 500;
221
274
  }
222
275
 
276
+ /* The citation chip. A superscript number a reader can press: small enough
277
+ to sit inside a sentence without breaking its rhythm, big enough that a
278
+ thumb finds it — the padding, not the glyph, carries the target size. */
279
+ .agent-prose :global(sup.ds-cite) {
280
+ display: inline-block;
281
+ min-width: 1.35em;
282
+ margin-inline: 0.15em;
283
+ padding: 0.1em 0.3em;
284
+ border-radius: 0.4em;
285
+ background: color-mix(in oklab, var(--primary) 16%, transparent);
286
+ color: var(--foreground);
287
+ font-family: var(--ds-font-mono, monospace);
288
+ font-size: 0.7em;
289
+ font-weight: 600;
290
+ line-height: 1.4;
291
+ text-align: center;
292
+ vertical-align: 0.35em;
293
+ cursor: pointer;
294
+ }
295
+
296
+ .agent-prose :global(sup.ds-cite:hover) {
297
+ background: color-mix(in oklab, var(--primary) 30%, transparent);
298
+ }
299
+
300
+ .agent-prose :global(sup.ds-cite:focus-visible) {
301
+ outline: 2px solid var(--ring);
302
+ outline-offset: 2px;
303
+ }
304
+
223
305
  .agent-prose :global(hr) {
224
306
  border: 0;
225
307
  border-top: 1px solid var(--border);
@@ -3,6 +3,12 @@ interface Props {
3
3
  streaming?: boolean;
4
4
  /** Collection names to chip in backticks; omit if the caller has none. */
5
5
  collectionNames?: Set<string>;
6
+ /** `[n]` markers to turn into chips; omit and the markers stay as text. */
7
+ citationNumbers?: Set<number>;
8
+ oncite?: (n: number) => void;
9
+ /** A step down, for prose that is reference material beside an answer
10
+ * rather than the answer itself — the source pane's sections. */
11
+ dense?: boolean;
6
12
  }
7
13
  declare const Markdown: import("svelte").Component<Props, {}, "">;
8
14
  type Markdown = ReturnType<typeof Markdown>;