@poodle64/librarian 2026.9.28 → 2026.10.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.
Files changed (46) hide show
  1. package/README.md +66 -116
  2. package/dist/attachments.d.ts +2 -2
  3. package/dist/attachments.js +3 -3
  4. package/dist/chat.svelte.d.ts +22 -23
  5. package/dist/chat.svelte.js +41 -64
  6. package/dist/citations.d.ts +7 -7
  7. package/dist/citations.js +6 -6
  8. package/dist/client.d.ts +16 -45
  9. package/dist/client.js +15 -23
  10. package/dist/components/activity-group/activity-group.svelte +1 -1
  11. package/dist/components/agent-transcript/agent-transcript.svelte +9 -110
  12. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +4 -16
  13. package/dist/components/answer-mark/answer-mark.svelte +2 -2
  14. package/dist/components/answer-mark/answer-mark.svelte.d.ts +2 -2
  15. package/dist/components/answer-share/answer-share.svelte +2 -2
  16. package/dist/components/answer-share/answer-share.svelte.d.ts +2 -2
  17. package/dist/components/artefact-card/artefact-card.svelte +2 -2
  18. package/dist/components/artefact-card/artefact-card.svelte.d.ts +2 -2
  19. package/dist/components/artefact-pane/artefact-pane.svelte +2 -2
  20. package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +2 -2
  21. package/dist/components/composer/composer.svelte +51 -112
  22. package/dist/components/composer/composer.svelte.d.ts +21 -18
  23. package/dist/components/composer/index.d.ts +1 -2
  24. package/dist/components/conversation/conversation.svelte +7 -18
  25. package/dist/components/conversation/conversation.svelte.d.ts +7 -12
  26. package/dist/components/conversation-list/conversation-list.svelte +2 -2
  27. package/dist/components/conversation-list/conversation-list.svelte.d.ts +2 -2
  28. package/dist/components/document-pane/document-pane.svelte +2 -2
  29. package/dist/components/document-pane/document-pane.svelte.d.ts +2 -2
  30. package/dist/components/fair-use-notice/fair-use-notice.svelte +2 -2
  31. package/dist/components/fair-use-notice/fair-use-notice.svelte.d.ts +2 -2
  32. package/dist/components/scope-statement/scope-statement.svelte +5 -5
  33. package/dist/components/scope-statement/scope-statement.svelte.d.ts +2 -2
  34. package/dist/components/source-list/source-list.svelte +2 -2
  35. package/dist/components/source-list/source-list.svelte.d.ts +2 -2
  36. package/dist/components/working/working.svelte +2 -2
  37. package/dist/components/working/working.svelte.d.ts +2 -2
  38. package/dist/copy.d.ts +16 -24
  39. package/dist/copy.js +10 -16
  40. package/dist/session.svelte.d.ts +1 -1
  41. package/dist/session.svelte.js +6 -7
  42. package/dist/transcript.svelte.d.ts +18 -32
  43. package/dist/transcript.svelte.js +19 -40
  44. package/package.json +1 -5
  45. package/dist/history.svelte.d.ts +0 -37
  46. package/dist/history.svelte.js +0 -59
@@ -24,7 +24,6 @@
24
24
  type Artefact,
25
25
  type Block,
26
26
  type DescribeTool,
27
- type Depth,
28
27
  type Outcome,
29
28
  type TextBlock
30
29
  } from '../../transcript.svelte';
@@ -34,7 +33,7 @@
34
33
  splitSources,
35
34
  type Citation
36
35
  } from '../../citations';
37
- import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
36
+ import { DEFAULT_PERSONA, personaName, resolveCopy, type ChatCopy } from '../../copy';
38
37
  import type { Verdict } from '../../chat.svelte';
39
38
  import AnswerMark from '../answer-mark/answer-mark.svelte';
40
39
  import AnswerShare from '../answer-share/answer-share.svelte';
@@ -51,22 +50,16 @@
51
50
  blocks: Block[];
52
51
  outcome: Outcome | null;
53
52
  running: boolean;
54
- /** Sources from the library's `citations` frame. Absent falls back to
53
+ /** Sources from the app's `citations` frame. Absent falls back to
55
54
  * whatever a "## Sources" block in the prose says. */
56
55
  citations?: Citation[];
57
56
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
58
57
  collectionNames?: Set<string>;
59
- /** Follow-ups from the library's `suggestions` frame. */
60
- suggestions?: string[];
61
58
  /** Opens the document pane. Omit and chips render but do not open. */
62
59
  oncite?: (citation: Citation) => void;
63
60
  /** Offered on the LAST answer only — re-asks the same question. */
64
61
  onregenerate?: () => void;
65
- /** Asks a follow-up. Offered on the LAST answer only; omit and the
66
- * chips do not render, because a chip that does nothing is worse than
67
- * no chip. */
68
- onsuggest?: (question: string) => void;
69
- copy?: Partial<LibrarianCopy>;
62
+ copy?: Partial<ChatCopy>;
70
63
  /** The persona's display name, signed on the answer card. */
71
64
  name?: string;
72
65
  /** When the question was asked, epoch ms. Rendered as a clock time —
@@ -83,8 +76,6 @@
83
76
  onopenartefact?: () => void;
84
77
  /** The persona's own words for its tools, and what they read. */
85
78
  describeTool?: DescribeTool;
86
- /** The question waits behind somebody else's. */
87
- waiting?: boolean;
88
79
  /** When an answer this page did not start began, epoch ms: it is still
89
80
  * being written, and the clock counts from then. */
90
81
  answering?: number;
@@ -98,10 +89,6 @@
98
89
  * true once recorded. Omit and nothing about sharing renders: nothing
99
90
  * is shared unless the reader asks. */
100
91
  onshare?: (share: boolean) => Promise<boolean>;
101
- /** How hard the persona worked on this one, for the footer's cost line
102
- * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
103
- * as it always has: the duration alone. */
104
- depth?: Depth;
105
92
  /** Prices `outcome.costUsd` in the host's own currency and words
106
93
  * ("about A$0.34"). The package never converts currency itself,
107
94
  * and a zero-cost run (a local model) shows no cost regardless. */
@@ -115,10 +102,8 @@
115
102
  running,
116
103
  citations = [],
117
104
  collectionNames = new Set(),
118
- suggestions = [],
119
105
  oncite,
120
106
  onregenerate,
121
- onsuggest,
122
107
  copy,
123
108
  name = DEFAULT_PERSONA,
124
109
  at,
@@ -126,12 +111,10 @@
126
111
  artefactOpen = false,
127
112
  onopenartefact,
128
113
  describeTool,
129
- waiting = false,
130
114
  answering,
131
115
  onmark,
132
116
  shared,
133
117
  onshare,
134
- depth,
135
118
  formatCost
136
119
  }: AgentTranscriptProps = $props();
137
120
 
@@ -214,7 +197,7 @@
214
197
  *
215
198
  * Reading none of this left a failed turn rendering as a clean, complete
216
199
  * answer: no banner, a duration badge, and — worse — the "answered without
217
- * a source" line, which is a claim about the shelf made off a run that
200
+ * a source" line, which is a claim about the sources made off a run that
218
201
  * never finished looking.
219
202
  */
220
203
  const failure = $derived(
@@ -248,13 +231,6 @@
248
231
  settled && hasAnswer && sources.length === 0 && Boolean(outcome) && !failure
249
232
  );
250
233
 
251
- // A follow-up is offered once. Clicking it asks the question, which puts a
252
- // new turn below this one — and leaving the row up for the moment before
253
- // that arrives invites a second click on a third suggestion, asking two
254
- // questions the reader only meant to ask one of.
255
- let used = $state(false);
256
- const followUps = $derived(!used && onsuggest ? suggestions : []);
257
-
258
234
  let copied = $state(false);
259
235
  // Not `copy`: the prop of that name is the package's own words, and a
260
236
  // function shadowing it here would be a redeclaration, not a shadow.
@@ -296,11 +272,7 @@
296
272
  {#if running && segments.length === 0}
297
273
  <Working
298
274
  copy={words}
299
- label={waiting
300
- ? words.waiting
301
- : answering !== undefined
302
- ? words.stillAnswering
303
- : undefined}
275
+ label={answering !== undefined ? words.stillAnswering : undefined}
304
276
  since={answering}
305
277
  />
306
278
  {/if}
@@ -332,7 +304,7 @@
332
304
  <SourceList {sources} {words} {oncite} />
333
305
  {/if}
334
306
 
335
- <!-- An empty `notHeld` is a host whose persona answers from no shelf,
307
+ <!-- An empty `notHeld` is a host whose persona answers from no sources,
336
308
  where "without a source" would be a claim about nothing. -->
337
309
  {#if unanswered && asked}
338
310
  <p class="ds-lib-not-held">{words.noAnswer}</p>
@@ -342,30 +314,6 @@
342
314
  <p class="ds-lib-not-held">{words.notHeld}</p>
343
315
  {/if}
344
316
 
345
- {#if !isArtefact && followUps.length > 0}
346
- <section>
347
- <h2 class="ds-lib-follow-heading">{words.suggestions}</h2>
348
- <!-- Wrapping, not scrolling: at 390 a question is most of a line, so
349
- a row of three would clip two of them to fragments. -->
350
- <ul class="ds-lib-chips">
351
- {#each followUps as followUp (followUp)}
352
- <li>
353
- <button
354
- type="button"
355
- class="ds-lib-chip"
356
- onclick={() => {
357
- used = true;
358
- onsuggest?.(followUp);
359
- }}
360
- >
361
- {followUp}
362
- </button>
363
- </li>
364
- {/each}
365
- </ul>
366
- </section>
367
- {/if}
368
-
369
317
  {#if failure}
370
318
  <p class="ds-lib-failure" role="alert">{failure}</p>
371
319
  {/if}
@@ -402,10 +350,10 @@
402
350
  {/if}
403
351
  <!-- What it took, which is not the same fact as when it was
404
352
  asked; both are on the card and neither stands in for the
405
- other. Depth and cost fold into the SAME line, never a
406
- second one, once the host offers either. -->
353
+ other. Cost folds into the SAME line, never a
354
+ second one, once the host offers it. -->
407
355
  {#if outcome && !failure}
408
- <span class="ds-lib-duration">{outcomeLine(outcome, depth, words, formatCost)}</span>
356
+ <span class="ds-lib-duration">{outcomeLine(outcome, formatCost)}</span>
409
357
  {/if}
410
358
  </div>
411
359
  {/if}
@@ -515,55 +463,6 @@
515
463
  font-size: 0.875rem;
516
464
  }
517
465
 
518
- .ds-lib-follow-heading {
519
- margin: 0 0 0.375rem;
520
- color: var(--ds-color-muted-foreground);
521
- font-family: inherit;
522
- font-size: var(--ds-text-2xs);
523
- font-weight: 500;
524
- letter-spacing: 0.05em;
525
- text-transform: uppercase;
526
- }
527
-
528
- .ds-lib-chips {
529
- display: flex;
530
- flex-wrap: wrap;
531
- gap: 0.5rem;
532
- margin: 0;
533
- padding: 0;
534
- list-style: none;
535
- }
536
-
537
- .ds-lib-chips li {
538
- max-width: 100%;
539
- }
540
-
541
- .ds-lib-chip {
542
- max-width: 100%;
543
- border: 1px solid var(--ds-color-border);
544
- border-radius: var(--ds-radius-full);
545
- background: none;
546
- padding: 0.5rem 0.875rem;
547
- color: var(--ds-color-foreground);
548
- font: inherit;
549
- font-size: 0.875rem;
550
- text-align: start;
551
- cursor: pointer;
552
- transition:
553
- background-color 150ms ease,
554
- border-color 150ms ease;
555
- }
556
-
557
- .ds-lib-chip:hover {
558
- border-color: var(--ds-color-border-strong);
559
- background: var(--ds-color-surface-1);
560
- }
561
-
562
- .ds-lib-chip:focus-visible {
563
- outline: 2px solid var(--ds-color-ring);
564
- outline-offset: 2px;
565
- }
566
-
567
466
  .ds-lib-failure {
568
467
  margin: 0;
569
468
  border: 1px solid color-mix(in oklab, var(--ds-color-status-error) 40%, transparent);
@@ -1,6 +1,6 @@
1
- import { type Artefact, type Block, type DescribeTool, type Depth, type Outcome } from '../../transcript.svelte';
1
+ import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
- import { type LibrarianCopy } from '../../copy';
3
+ import { type ChatCopy } from '../../copy';
4
4
  import type { Verdict } from '../../chat.svelte';
5
5
  /** Everything one turn renders from: what `Conversation` hands a host's
6
6
  * own `turn` snippet, so it can render this component or its own. */
@@ -9,22 +9,16 @@ export interface AgentTranscriptProps {
9
9
  blocks: Block[];
10
10
  outcome: Outcome | null;
11
11
  running: boolean;
12
- /** Sources from the library's `citations` frame. Absent falls back to
12
+ /** Sources from the app's `citations` frame. Absent falls back to
13
13
  * whatever a "## Sources" block in the prose says. */
14
14
  citations?: Citation[];
15
15
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
16
16
  collectionNames?: Set<string>;
17
- /** Follow-ups from the library's `suggestions` frame. */
18
- suggestions?: string[];
19
17
  /** Opens the document pane. Omit and chips render but do not open. */
20
18
  oncite?: (citation: Citation) => void;
21
19
  /** Offered on the LAST answer only — re-asks the same question. */
22
20
  onregenerate?: () => void;
23
- /** Asks a follow-up. Offered on the LAST answer only; omit and the
24
- * chips do not render, because a chip that does nothing is worse than
25
- * no chip. */
26
- onsuggest?: (question: string) => void;
27
- copy?: Partial<LibrarianCopy>;
21
+ copy?: Partial<ChatCopy>;
28
22
  /** The persona's display name, signed on the answer card. */
29
23
  name?: string;
30
24
  /** When the question was asked, epoch ms. Rendered as a clock time —
@@ -41,8 +35,6 @@ export interface AgentTranscriptProps {
41
35
  onopenartefact?: () => void;
42
36
  /** The persona's own words for its tools, and what they read. */
43
37
  describeTool?: DescribeTool;
44
- /** The question waits behind somebody else's. */
45
- waiting?: boolean;
46
38
  /** When an answer this page did not start began, epoch ms: it is still
47
39
  * being written, and the clock counts from then. */
48
40
  answering?: number;
@@ -56,10 +48,6 @@ export interface AgentTranscriptProps {
56
48
  * true once recorded. Omit and nothing about sharing renders: nothing
57
49
  * is shared unless the reader asks. */
58
50
  onshare?: (share: boolean) => Promise<boolean>;
59
- /** How hard the persona worked on this one, for the footer's cost line
60
- * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
61
- * as it always has: the duration alone. */
62
- depth?: Depth;
63
51
  /** Prices `outcome.costUsd` in the host's own currency and words
64
52
  * ("about A$0.34"). The package never converts currency itself,
65
53
  * and a zero-cost run (a local model) shows no cost regardless. */
@@ -18,13 +18,13 @@
18
18
  import ThumbsDownIcon from '@lucide/svelte/icons/thumbs-down';
19
19
  import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up';
20
20
  import type { Verdict } from '../../chat.svelte';
21
- import { resolveCopy, type LibrarianCopy } from '../../copy';
21
+ import { resolveCopy, type ChatCopy } from '../../copy';
22
22
 
23
23
  interface Props {
24
24
  /** Records a verdict, replacing any before it. Resolves true once it is
25
25
  * recorded. */
26
26
  onmark: (verdict: Verdict) => Promise<boolean>;
27
- copy?: Partial<LibrarianCopy>;
27
+ copy?: Partial<ChatCopy>;
28
28
  }
29
29
 
30
30
  let { onmark, copy }: Props = $props();
@@ -1,10 +1,10 @@
1
1
  import type { Verdict } from '../../chat.svelte';
2
- import { type LibrarianCopy } from '../../copy';
2
+ import { type ChatCopy } from '../../copy';
3
3
  interface Props {
4
4
  /** Records a verdict, replacing any before it. Resolves true once it is
5
5
  * recorded. */
6
6
  onmark: (verdict: Verdict) => Promise<boolean>;
7
- copy?: Partial<LibrarianCopy>;
7
+ copy?: Partial<ChatCopy>;
8
8
  }
9
9
  declare const AnswerMark: import("svelte").Component<Props, {}, "">;
10
10
  type AnswerMark = ReturnType<typeof AnswerMark>;
@@ -13,7 +13,7 @@
13
13
  import CheckIcon from '@lucide/svelte/icons/check';
14
14
  import LinkIcon from '@lucide/svelte/icons/link';
15
15
  import Share2Icon from '@lucide/svelte/icons/share-2';
16
- import { resolveCopy, type LibrarianCopy } from '../../copy';
16
+ import { resolveCopy, type ChatCopy } from '../../copy';
17
17
 
18
18
  interface Props {
19
19
  /** Shares the answer (true) or stops sharing it (false), resolving true
@@ -22,7 +22,7 @@
22
22
  /** Where the read-only view lives while the answer is shared, as the
23
23
  * host's own address. */
24
24
  shared?: string;
25
- copy?: Partial<LibrarianCopy>;
25
+ copy?: Partial<ChatCopy>;
26
26
  }
27
27
 
28
28
  let { onshare, shared, copy }: Props = $props();
@@ -1,4 +1,4 @@
1
- import { type LibrarianCopy } from '../../copy';
1
+ import { type ChatCopy } from '../../copy';
2
2
  interface Props {
3
3
  /** Shares the answer (true) or stops sharing it (false), resolving true
4
4
  * once recorded. */
@@ -6,7 +6,7 @@ interface Props {
6
6
  /** Where the read-only view lives while the answer is shared, as the
7
7
  * host's own address. */
8
8
  shared?: string;
9
- copy?: Partial<LibrarianCopy>;
9
+ copy?: Partial<ChatCopy>;
10
10
  }
11
11
  declare const AnswerShare: import("svelte").Component<Props, {}, "">;
12
12
  type AnswerShare = ReturnType<typeof AnswerShare>;
@@ -12,7 +12,7 @@
12
12
  <script lang="ts">
13
13
  import FileTextIcon from '@lucide/svelte/icons/file-text';
14
14
  import type { Artefact } from '../../transcript.svelte';
15
- import { resolveCopy, type LibrarianCopy } from '../../copy';
15
+ import { resolveCopy, type ChatCopy } from '../../copy';
16
16
 
17
17
  interface Props {
18
18
  artefact: Artefact;
@@ -21,7 +21,7 @@
21
21
  /** Omit and the card informs but does not open: a host with nowhere
22
22
  * to show the artefact offers no action rather than a dead one. */
23
23
  onopen?: () => void;
24
- copy?: Partial<LibrarianCopy>;
24
+ copy?: Partial<ChatCopy>;
25
25
  }
26
26
 
27
27
  let { artefact, open = false, onopen, copy }: Props = $props();
@@ -1,5 +1,5 @@
1
1
  import type { Artefact } from '../../transcript.svelte';
2
- import { type LibrarianCopy } from '../../copy';
2
+ import { type ChatCopy } from '../../copy';
3
3
  interface Props {
4
4
  artefact: Artefact;
5
5
  /** The host is showing it now. */
@@ -7,7 +7,7 @@ interface Props {
7
7
  /** Omit and the card informs but does not open: a host with nowhere
8
8
  * to show the artefact offers no action rather than a dead one. */
9
9
  onopen?: () => void;
10
- copy?: Partial<LibrarianCopy>;
10
+ copy?: Partial<ChatCopy>;
11
11
  }
12
12
  declare const ArtefactCard: import("svelte").Component<Props, {}, "">;
13
13
  type ArtefactCard = ReturnType<typeof ArtefactCard>;
@@ -10,7 +10,7 @@
10
10
  import XIcon from '@lucide/svelte/icons/x';
11
11
  import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
12
12
  import { resolveCitations, splitSources, type Citation } from '../../citations';
13
- import { resolveCopy, type LibrarianCopy } from '../../copy';
13
+ import { resolveCopy, type ChatCopy } from '../../copy';
14
14
  import Markdown from '../markdown/markdown.svelte';
15
15
  import SourceList from '../source-list/source-list.svelte';
16
16
 
@@ -21,7 +21,7 @@
21
21
  * stacking it beside the artefact. Omit and the sources still list, but
22
22
  * do not open. */
23
23
  oncite?: (citation: Citation) => void;
24
- copy?: Partial<LibrarianCopy>;
24
+ copy?: Partial<ChatCopy>;
25
25
  }
26
26
 
27
27
  let { turn, onclose, oncite, copy }: Props = $props();
@@ -1,6 +1,6 @@
1
1
  import { type Turn } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
- import { type LibrarianCopy } from '../../copy';
3
+ import { type ChatCopy } from '../../copy';
4
4
  interface Props {
5
5
  turn: Turn;
6
6
  onclose: () => void;
@@ -8,7 +8,7 @@ interface Props {
8
8
  * stacking it beside the artefact. Omit and the sources still list, but
9
9
  * do not open. */
10
10
  oncite?: (citation: Citation) => void;
11
- copy?: Partial<LibrarianCopy>;
11
+ copy?: Partial<ChatCopy>;
12
12
  }
13
13
  declare const ArtefactPane: import("svelte").Component<Props, {}, "">;
14
14
  type ArtefactPane = ReturnType<typeof ArtefactPane>;
@@ -6,7 +6,7 @@
6
6
  The empty→typing transition lives almost entirely in that control — the box
7
7
  chrome grows with the text and nothing else moves.
8
8
 
9
- While Milton is answering the box is disabled and the send button becomes a
9
+ While the persona is answering the box is disabled and the send button becomes a
10
10
  stop button: an ask route takes one question per request, and a box that
11
11
  takes typing it will then discard is worse than one that plainly waits. A
12
12
  host whose session reads messages while it works (`sendWhileRunning`) keeps
@@ -26,30 +26,46 @@
26
26
  MAX_FILES,
27
27
  rejectionMessage
28
28
  } from '../../attachments';
29
- import type { Depth } from '../../client';
30
- import { DEFAULT_PERSONA, resolveCopy, type LibrarianCopy } from '../../copy';
29
+ import { DEFAULT_PERSONA, resolveCopy, type ChatCopy } from '../../copy';
31
30
 
32
31
  /** Past this many turns, the long-conversation banner offers to start
33
32
  * over: a follow-up re-reads the whole conversation, so each one costs
34
33
  * more than the last (measured — the longest ran 2.05M cached tokens). */
35
34
  const DEFAULT_TURN_LIMIT = 8;
36
35
 
37
- export type Scope = 'document' | 'collection' | 'library';
36
+ /** A collection a person may read, as the app lists it: `name` is what a
37
+ * question is narrowed to, `title` is what the picker shows (the name,
38
+ * made readable, where there is none) and `description` is its one-line
39
+ * hover. A retrieval summary is never a field here: it is filing history. */
40
+ export interface CollectionChoice {
41
+ name: string;
42
+ title?: string;
43
+ description?: string;
44
+ }
45
+
46
+ /** `defence-housing` as a reader would first read it: "Defence housing". */
47
+ function titleOf(choice: CollectionChoice): string {
48
+ const given = choice.title?.trim();
49
+ if (given) return given;
50
+ const words = choice.name.replace(/[-_]+/g, ' ').trim();
51
+ return words ? words[0]!.toLocaleUpperCase() + words.slice(1) : choice.name;
52
+ }
38
53
 
39
54
  interface Props {
40
55
  value: string;
41
56
  running: boolean;
42
- /** What the next message is about. A surface with one scope — a
43
- * session over one document — passes none and offers no choice. */
44
- scope?: Scope;
57
+ /** The collections this person may read. The picker renders when there
58
+ * are two or more: with one, or none, there is nothing to choose. */
59
+ collections?: CollectionChoice[];
60
+ /** The collection the next question is narrowed to, by name; undefined
61
+ * asks across all of them. */
62
+ collection?: string;
45
63
  /** Files to send with the next question. Cleared by the caller on send. */
46
64
  files?: File[];
47
- /** Names for the two narrower scopes; absent means that scope is unavailable. */
48
- documentName?: string;
49
- collectionName?: string;
50
65
  /** Hides the paperclip for a host whose ask route takes no files. */
51
66
  attachments?: boolean;
52
- onscope?: (scope: Scope) => void;
67
+ /** The person picked a collection by name, or all of them (undefined). */
68
+ oncollection?: (collection: string | undefined) => void;
53
69
  onsubmit: () => void;
54
70
  onstop: () => void;
55
71
  /** Asks for a study artefact instead of an ordinary answer. Omit and no
@@ -60,21 +76,16 @@
60
76
  * both placeholders are composed from it. */
61
77
  name?: string;
62
78
  /** Overrides for the package's own words. */
63
- copy?: Partial<LibrarianCopy>;
79
+ copy?: Partial<ChatCopy>;
64
80
  /** The session reads a message sent while it works — a job whose
65
81
  * route writes it onto the running process — so the box stays open
66
82
  * and Send sits beside Stop. Words only: the paperclip still waits for
67
83
  * the run. Absent, a chat waits for its answer. */
68
84
  sendWhileRunning?: boolean;
69
- /** One quiet line in the box's footer, where a scope choice would
85
+ /** One quiet line in the box's footer, where the collection picker would
70
86
  * sit, in the host's words: what sending does here ("It carries on
71
87
  * from where it stopped."). */
72
88
  note?: string;
73
- /** Quick or Thorough for the next question, as a two-way switch with a
74
- * one-line hint. Bind it and pass it to `ask()`; omit it and nothing
75
- * renders. Once shown it is never disabled or hidden, and never names
76
- * the model behind it. */
77
- depth?: Depth;
78
89
  /** How many turns are in the conversation so far. Past `turnLimit`
79
90
  * (8 by default), a quiet banner appears above the box offering to
80
91
  * start over. Omit and no banner ever renders — a host that does not
@@ -90,12 +101,11 @@
90
101
  let {
91
102
  value = $bindable(),
92
103
  running,
93
- scope,
104
+ collections = [],
105
+ collection,
94
106
  files = $bindable([]),
95
- documentName,
96
- collectionName,
97
107
  attachments = true,
98
- onscope,
108
+ oncollection,
99
109
  onsubmit,
100
110
  onstop,
101
111
  onbriefing,
@@ -103,7 +113,6 @@
103
113
  copy,
104
114
  note,
105
115
  sendWhileRunning = false,
106
- depth = $bindable(),
107
116
  turnCount,
108
117
  turnLimit = DEFAULT_TURN_LIMIT,
109
118
  onnewquestion
@@ -120,24 +129,11 @@
120
129
  turnCount !== undefined && turnCount > turnLimit && Boolean(onnewquestion)
121
130
  );
122
131
 
123
- // What Milton is being asked about — this document, this collection, or
124
- // the whole library. Callers that have no narrower scope pass neither
125
- // name, leaving "The whole library" the sole entry — and a sole entry is
126
- // not a choice, so `hasChoice` below hides it rather than rendering a
127
- // chip with nothing to switch to.
128
- const choices = $derived(
129
- [
130
- documentName ? { id: 'document' as const, label: documentName } : null,
131
- collectionName ? { id: 'collection' as const, label: collectionName } : null,
132
- { id: 'library' as const, label: 'The whole library' }
133
- ].filter((c) => c !== null)
134
- );
135
-
136
- // A room with one shelf set has one scope, fixed by the caller never
137
- // passing a name — no pick to make, so the row renders nothing rather
138
- // than a chip that only ever reselects itself (design-system, the
139
- // fixed-scope Composer defect).
140
- const hasChoice = $derived(scope !== undefined && choices.length > 1);
132
+ // Who the question is put to: every collection the person may read, or one
133
+ // of them. A person with one collection (or none) has nothing to pick, so
134
+ // the row renders nothing rather than a chip that only reselects itself.
135
+ // A collection is shown by its title, the words a reader knows it by.
136
+ const hasChoice = $derived(collections.length > 1);
141
137
 
142
138
  // Three chips in a narrow column clipped all three to fragments
143
139
  // ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
@@ -166,7 +162,7 @@
166
162
  grow();
167
163
  });
168
164
 
169
- // Focus comes back once Milton is done, so a reader can keep going without
165
+ // Focus comes back once the persona is done, so a reader can keep going without
170
166
  // reaching for the mouse. It has to be the transition OUT of `waiting`: a
171
167
  // disabled element cannot hold focus, so the focus call in `submit()` below
172
168
  // survives only for a box that never disables — measured, focus lands on
@@ -321,41 +317,27 @@
321
317
  </button>
322
318
  {/if}
323
319
 
324
- {#if depth !== undefined}
325
- <div role="group" aria-label={words.depthGroupLabel} class="ds-lib-depth">
326
- <button
327
- type="button"
328
- aria-pressed={depth === 'quick'}
329
- class="ds-lib-depth-side"
330
- class:is-chosen={depth === 'quick'}
331
- onclick={() => (depth = 'quick')}
332
- >
333
- {words.depthQuick}
334
- </button>
320
+ {#if hasChoice}
321
+ <div role="group" aria-label={words.collectionsLabel} class="ds-lib-scopes">
335
322
  <button
336
323
  type="button"
337
- aria-pressed={depth === 'thorough'}
338
- class="ds-lib-depth-side"
339
- class:is-chosen={depth === 'thorough'}
340
- onclick={() => (depth = 'thorough')}
324
+ class="ds-lib-scope-chip"
325
+ aria-pressed={collection === undefined}
326
+ class:is-chosen={collection === undefined}
327
+ onclick={() => oncollection?.(undefined)}
341
328
  >
342
- {words.depthThorough}
329
+ {words.allCollections}
343
330
  </button>
344
- </div>
345
- <span class="ds-lib-note">
346
- {depth === 'quick' ? words.depthQuickHint : words.depthThoroughHint}
347
- </span>
348
- {/if}
349
- {#if hasChoice}
350
- <div class="ds-lib-scopes">
351
- {#each choices as choice (choice.id)}
331
+ {#each collections as choice (choice.name)}
352
332
  <button
353
333
  type="button"
354
334
  class="ds-lib-scope-chip"
355
- class:is-chosen={scope === choice.id}
356
- onclick={() => onscope?.(choice.id)}
335
+ aria-pressed={collection === choice.name}
336
+ class:is-chosen={collection === choice.name}
337
+ title={choice.description || undefined}
338
+ onclick={() => oncollection?.(choice.name)}
357
339
  >
358
- {choice.label}
340
+ {titleOf(choice)}
359
341
  </button>
360
342
  {/each}
361
343
  </div>
@@ -607,49 +589,6 @@
607
589
  font-size: var(--ds-text-2xs);
608
590
  }
609
591
 
610
- /* A labelled button group, never a select: two states a reader compares
611
- at a glance, not a list to open. 44px touch targets throughout — the
612
- one row here a colleague reaches for on every single question. */
613
- .ds-lib-depth {
614
- display: flex;
615
- flex: none;
616
- gap: 0.125rem;
617
- border-radius: var(--ds-radius-lg);
618
- background: var(--ds-color-surface-3);
619
- padding: 0.1875rem;
620
- }
621
-
622
- .ds-lib-depth-side {
623
- min-width: 2.75rem;
624
- min-height: 2.75rem;
625
- border: 0;
626
- border-radius: var(--ds-radius-md);
627
- background: none;
628
- padding: 0 0.75rem;
629
- color: var(--ds-color-muted-foreground);
630
- font: inherit;
631
- font-size: var(--ds-text-2xs);
632
- cursor: pointer;
633
- transition:
634
- color 150ms ease,
635
- background-color 150ms ease;
636
- }
637
-
638
- .ds-lib-depth-side:hover {
639
- color: var(--ds-color-foreground);
640
- }
641
-
642
- .ds-lib-depth-side.is-chosen {
643
- background: var(--ds-color-primary);
644
- color: var(--ds-color-primary-foreground);
645
- font-weight: 600;
646
- }
647
-
648
- .ds-lib-depth-side:focus-visible {
649
- outline: 2px solid var(--ds-color-ring);
650
- outline-offset: 2px;
651
- }
652
-
653
592
  /* Above the box, never inside it: it is about the conversation so far,
654
593
  not this one question. Warning-tinted, never error — nothing has gone
655
594
  wrong, a follow-up just costs more than the last one did. */