@poodle64/librarian 2026.9.5 → 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 (34) hide show
  1. package/README.md +143 -44
  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 +39 -13
  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 +212 -59
  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/components/tool-row/tool-row.svelte +5 -11
  29. package/dist/components/working/working.svelte +1 -9
  30. package/dist/follow-scroll.svelte.d.ts +34 -0
  31. package/dist/follow-scroll.svelte.js +46 -0
  32. package/dist/transcript.svelte.d.ts +36 -4
  33. package/dist/transcript.svelte.js +50 -26
  34. package/package.json +16 -2
@@ -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>;
@@ -5,14 +5,13 @@
5
5
  dimmed sub-line. Borders and badge pills make a run of five calls read as
6
6
  five separate events rather than one train of thought.
7
7
 
8
- The row says what the agent is DOING, not what it typed. A reader here is
9
- asking about documents, not reading a terminal, and `Bash ls -1 .` looks
10
- like a leak from the machine room. The raw command is one click away, so
11
- nothing is hidden from anyone who wants it.
8
+ The row says what Milton DID, in a reader's own words — never the tool's
9
+ name or the raw command it ran. Expanding a settled row shows what came
10
+ back, never what was typed.
12
11
  -->
13
12
  <script lang="ts">
14
13
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
15
- import { describe, summarise, type ToolBlock } from '../../transcript.svelte';
14
+ import { describe, type ToolBlock } from '../../transcript.svelte';
16
15
 
17
16
  interface Props {
18
17
  block: ToolBlock;
@@ -53,17 +52,12 @@
53
52
  <span class="text-muted-foreground truncate">
54
53
  <span class="text-foreground font-medium">{said.verb}</span>
55
54
  {#if said.object}<span class="text-foreground/80">{said.object}</span>{/if}
56
- {#if repeats > 1}<span class="text-muted-foreground">· {repeats} sections</span>{/if}
55
+ {#if repeats > 1}<span class="text-muted-foreground">· {repeats} pages</span>{/if}
57
56
  </span>
58
57
  </button>
59
58
 
60
59
  {#if open}
61
60
  <div class="border-border mt-1 ml-5.5 border-l pl-3">
62
- <!-- The real command, for anyone who wants it. -->
63
- <p class="text-muted-foreground font-mono text-xs break-all">
64
- {block.name}
65
- {summarise(block)}
66
- </p>
67
61
  {#if block.result}
68
62
  <pre
69
63
  class="text-muted-foreground mt-1 max-h-72 overflow-auto font-mono text-xs whitespace-pre-wrap">{block.result}</pre>
@@ -18,15 +18,7 @@
18
18
 
19
19
  let { label }: Props = $props();
20
20
 
21
- const WORDS = [
22
- 'Starting',
23
- 'Reading the shelves',
24
- 'Rummaging',
25
- 'Cross-checking',
26
- 'Thumbing pages',
27
- 'Following a reference',
28
- 'Chasing it down'
29
- ];
21
+ const WORDS = ['Milton is looking', 'Milton is reading'];
30
22
 
31
23
  let tick = $state(0);
32
24
  let elapsed = $state(0);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Follow the stream until the reader disagrees.
3
+ *
4
+ * The whole behaviour is one rule stated three ways: a viewport already at the
5
+ * bottom is following; a reader who scrolls UP has stopped following; a reader
6
+ * who comes back to the bottom is following again. Nothing else — no timers,
7
+ * no "was that scroll ours or theirs" bookkeeping — because the auto-scroll
8
+ * this drives always lands AT the bottom, which the first clause re-affirms
9
+ * rather than fights.
10
+ *
11
+ * Growing content moves the bottom away from a parked reader without moving
12
+ * their scrollTop, so distance alone cannot say who moved: the direction of
13
+ * scrollTop can, and that is the only thing `measure` remembers between calls.
14
+ */
15
+ /** Distance from the bottom, in px, still counted as being at the bottom.
16
+ * A line of prose is ~28px; two lines of slack survives sub-pixel layout
17
+ * rounding and a caret-height change without unpinning the reader. */
18
+ export declare const FOLLOW_THRESHOLD = 64;
19
+ export interface ScrollMetrics {
20
+ scrollTop: number;
21
+ clientHeight: number;
22
+ scrollHeight: number;
23
+ }
24
+ export declare function distanceFromBottom(m: ScrollMetrics): number;
25
+ export declare function atBottom(m: ScrollMetrics, threshold?: number): boolean;
26
+ export declare class FollowScroll {
27
+ #private;
28
+ /** True while the viewport should be dragged along with new content. */
29
+ following: boolean;
30
+ /** Feed every scroll event, and the metrics after every content change. */
31
+ measure(m: ScrollMetrics, threshold?: number): void;
32
+ /** A new question re-pins: the reader asked for the thing about to arrive. */
33
+ pin(): void;
34
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Follow the stream until the reader disagrees.
3
+ *
4
+ * The whole behaviour is one rule stated three ways: a viewport already at the
5
+ * bottom is following; a reader who scrolls UP has stopped following; a reader
6
+ * who comes back to the bottom is following again. Nothing else — no timers,
7
+ * no "was that scroll ours or theirs" bookkeeping — because the auto-scroll
8
+ * this drives always lands AT the bottom, which the first clause re-affirms
9
+ * rather than fights.
10
+ *
11
+ * Growing content moves the bottom away from a parked reader without moving
12
+ * their scrollTop, so distance alone cannot say who moved: the direction of
13
+ * scrollTop can, and that is the only thing `measure` remembers between calls.
14
+ */
15
+ /** Distance from the bottom, in px, still counted as being at the bottom.
16
+ * A line of prose is ~28px; two lines of slack survives sub-pixel layout
17
+ * rounding and a caret-height change without unpinning the reader. */
18
+ export const FOLLOW_THRESHOLD = 64;
19
+ export function distanceFromBottom(m) {
20
+ return m.scrollHeight - m.scrollTop - m.clientHeight;
21
+ }
22
+ export function atBottom(m, threshold = FOLLOW_THRESHOLD) {
23
+ return distanceFromBottom(m) <= threshold;
24
+ }
25
+ export class FollowScroll {
26
+ /** True while the viewport should be dragged along with new content. */
27
+ following = $state(true);
28
+ #lastTop = 0;
29
+ /** Feed every scroll event, and the metrics after every content change. */
30
+ measure(m, threshold = FOLLOW_THRESHOLD) {
31
+ // 1px of tolerance: a trackpad's fractional scrollTop otherwise reads as
32
+ // an upward flick on a viewport that has not actually moved.
33
+ const wentUp = m.scrollTop < this.#lastTop - 1;
34
+ this.#lastTop = m.scrollTop;
35
+ if (atBottom(m, threshold)) {
36
+ this.following = true;
37
+ return;
38
+ }
39
+ if (wentUp)
40
+ this.following = false;
41
+ }
42
+ /** A new question re-pins: the reader asked for the thing about to arrive. */
43
+ pin() {
44
+ this.following = true;
45
+ }
46
+ }
@@ -7,6 +7,7 @@
7
7
  * verbatim under `other`, so nothing is silently dropped.
8
8
  */
9
9
  import type { AgentEvent } from './client';
10
+ import type { Citation } from './citations';
10
11
  export interface TextBlock {
11
12
  kind: 'text';
12
13
  index: number;
@@ -34,12 +35,25 @@ export interface Outcome {
34
35
  isError?: boolean;
35
36
  error?: string;
36
37
  }
37
- /** What the agent is DOING, in words a reader who has never seen a shell knows.
38
+ /**
39
+ * The question as the READER asked it.
40
+ *
41
+ * A host prepends a system preamble to every question before it goes to the
42
+ * library — cadmus sends `{room.preamble}\n\n{question}` — and echoing that
43
+ * back into the transcript shows a colleague the machine room. Leading
44
+ * blank-line-separated paragraphs addressed to the model are dropped.
45
+ *
46
+ * The LAST paragraph is never dropped, whatever it starts with: someone whose
47
+ * entire question is "You are wrong about the leave rule" must still see it.
48
+ */
49
+ export declare function readerQuestion(question: string): string;
50
+ /** What Milton DID, in a reader's own words — never the tool's name or the
51
+ * raw command it ran.
38
52
  *
39
53
  * The collapsed row is read by someone asking a question about documents, not
40
54
  * by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
41
- * the machine room. The raw command is still one click away on expand, so this
42
- * hides nothing — it just stops the transcript opening with jargon.
55
+ * the machine room. Milton never narrates how he searched, so a tool this
56
+ * does not recognise falls back to something that names no mechanism at all.
43
57
  */
44
58
  export declare function describe(block: ToolBlock): {
45
59
  verb: string;
@@ -68,6 +82,8 @@ export declare class Transcript {
68
82
  sessionId: string | null;
69
83
  model: string | null;
70
84
  outcome: Outcome | null;
85
+ /** Sources for the answer, from the library's own `citations` frame. */
86
+ citations: Citation[];
71
87
  other: AgentEvent[];
72
88
  reset(): void;
73
89
  apply(event: AgentEvent): void;
@@ -88,6 +104,18 @@ export interface ActivityGroup {
88
104
  searches: number;
89
105
  }
90
106
  export type Segment = ActivityGroup | TextBlock;
107
+ /** One question and the answer to it, as the transcript renders it.
108
+ *
109
+ * A finished turn is a plain object the host keeps in a list; the LIVE turn is
110
+ * a `Transcript` spread into the same shape. Both render identically, which is
111
+ * what stops a conversation flickering as the last turn settles. */
112
+ export interface Turn {
113
+ id: string;
114
+ question: string;
115
+ blocks: Block[];
116
+ outcome: Outcome | null;
117
+ citations?: Citation[];
118
+ }
91
119
  /**
92
120
  * Fold a turn's flat block list into what a reader should actually see.
93
121
  *
@@ -101,5 +129,9 @@ export type Segment = ActivityGroup | TextBlock;
101
129
  * steps with the same label collapse to one row carrying a count.
102
130
  */
103
131
  export declare function segment(blocks: Block[]): Segment[];
104
- /** One line describing a whole investigation, for the collapsed state. */
132
+ /** One line describing a whole investigation, for the collapsed state.
133
+ *
134
+ * Counts of what Milton did are fine ("1 search · 2 documents read"); how he
135
+ * organises what he knows is not — no collection count, no shelf, no corpus.
136
+ */
105
137
  export declare function summariseActivity(group: ActivityGroup): string;