@poodle64/librarian 2026.10.6 → 2026.10.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.
@@ -205,11 +205,13 @@ export declare class Chat {
205
205
  new(): void;
206
206
  /**
207
207
  * Ask `question`, or what is in the box, as a `kind` of turn when it is not
208
- * a plain question, narrowed to one `collection` by name when given. Resolves
209
- * once the turn settles: true if it was asked, false if it was refused,
210
- * here or by the server, in which case typed words go back into the box.
208
+ * a plain question, narrowed to one `collection` by name when given. A
209
+ * question handed in carries `files` with it, the box's own going with a
210
+ * typed one. Resolves once the turn settles: true if it was asked, false if
211
+ * it was refused, here or by the server, in which case the question and its
212
+ * files go back into the box, handed or typed.
211
213
  */
212
- ask(question?: string, kind?: string, collection?: string): Promise<boolean>;
214
+ ask(question?: string, kind?: string, collection?: string, files?: File[]): Promise<boolean>;
213
215
  /** The last question again, as a new turn: the conversation is append-only,
214
216
  * because the agent's transcript is. Same kind, same collection. */
215
217
  again(): Promise<boolean>;
@@ -144,25 +144,31 @@ export class Chat {
144
144
  }
145
145
  /**
146
146
  * Ask `question`, or what is in the box, as a `kind` of turn when it is not
147
- * a plain question, narrowed to one `collection` by name when given. Resolves
148
- * once the turn settles: true if it was asked, false if it was refused,
149
- * here or by the server, in which case typed words go back into the box.
147
+ * a plain question, narrowed to one `collection` by name when given. A
148
+ * question handed in carries `files` with it, the box's own going with a
149
+ * typed one. Resolves once the turn settles: true if it was asked, false if
150
+ * it was refused, here or by the server, in which case the question and its
151
+ * files go back into the box, handed or typed.
150
152
  */
151
- async ask(question, kind, collection) {
153
+ async ask(question, kind, collection, files = []) {
152
154
  const typed = question === undefined;
153
155
  const text = (question ?? this.draft).trim();
154
156
  if (!text)
155
157
  return false;
156
158
  if (this.#job)
157
159
  return this.#message(text, typed);
158
- if (this.busy || this.quota?.reached)
160
+ if (this.busy || this.quota?.reached) {
161
+ // A handed question has no other home: the box takes it, as it takes a refused one.
162
+ if (!typed)
163
+ this.#restore(text, files);
159
164
  return false;
160
- const files = typed ? this.files : [];
165
+ }
166
+ const sent = typed ? this.files : files;
161
167
  if (typed) {
162
168
  this.draft = '';
163
169
  this.files = [];
164
170
  }
165
- return this.#ask(text, files, typed, kind, collection);
171
+ return this.#ask(text, sent, typed, kind, collection);
166
172
  }
167
173
  /** The last question again, as a new turn: the conversation is append-only,
168
174
  * because the agent's transcript is. Same kind, same collection. */
@@ -419,10 +425,7 @@ export class Chat {
419
425
  const at = this.#turns.findIndex((t) => t.id === live.id);
420
426
  if (at >= 0)
421
427
  this.#turns.splice(at, 1);
422
- if (typed) {
423
- this.draft = text;
424
- this.files = files;
425
- }
428
+ this.#restore(text, files);
426
429
  this.#finish();
427
430
  // Asked elsewhere and still being answered: show that instead.
428
431
  if (ending === 'answering' && id)
@@ -564,6 +567,11 @@ export class Chat {
564
567
  return;
565
568
  }
566
569
  }
570
+ /** A question that was not asked goes back into the box with its files, ahead of anything typed since. */
571
+ #restore(text, files) {
572
+ this.draft = this.draft ? `${text}\n\n${this.draft}` : text;
573
+ this.files = [...files, ...this.files];
574
+ }
567
575
  async #message(text, typed) {
568
576
  const id = this.conversationId;
569
577
  if (!id)
@@ -576,6 +584,8 @@ export class Chat {
576
584
  catch {
577
585
  if (typed)
578
586
  this.draft = text;
587
+ else
588
+ this.#restore(text, []);
579
589
  return false;
580
590
  }
581
591
  // A settled job takes it as a new run, already started when the
@@ -18,6 +18,7 @@
18
18
  import PencilIcon from '@lucide/svelte/icons/pencil';
19
19
  import FileTextIcon from '@lucide/svelte/icons/file-text';
20
20
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
21
+ import type { Snippet } from 'svelte';
21
22
  import {
22
23
  outcomeLine,
23
24
  readerQuestion,
@@ -116,6 +117,9 @@
116
117
  * ("about A$0.34"). The package never converts currency itself,
117
118
  * and a zero-cost run (a local model) shows no cost regardless. */
118
119
  formatCost?: (usd: number) => string;
120
+ /** Drawn in place of the package's own "working" line while the answer has
121
+ * shown nothing yet; given the line's `label` and `since`. */
122
+ working?: Snippet<[{ label?: string; since?: number }]>;
119
123
  }
120
124
 
121
125
  let {
@@ -145,7 +149,8 @@
145
149
  onmark,
146
150
  shared,
147
151
  onshare,
148
- formatCost
152
+ formatCost,
153
+ working
149
154
  }: AgentTranscriptProps = $props();
150
155
 
151
156
  // The prose IS the artefact: it reads in the column, and the transcript
@@ -390,11 +395,18 @@
390
395
  </header>
391
396
 
392
397
  {#if running && segments.length === 0}
393
- <Working
394
- copy={words}
395
- label={answering !== undefined ? words.stillAnswering : undefined}
396
- since={answering}
397
- />
398
+ {#if working}
399
+ {@render working({
400
+ label: answering !== undefined ? words.stillAnswering : undefined,
401
+ since: answering
402
+ })}
403
+ {:else}
404
+ <Working
405
+ copy={words}
406
+ label={answering !== undefined ? words.stillAnswering : undefined}
407
+ since={answering}
408
+ />
409
+ {/if}
398
410
  {/if}
399
411
 
400
412
  {#each segments as seg (seg.index)}
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
2
3
  import { type Citation } from '../../citations';
3
4
  import { type ChatCopy } from '../../copy';
@@ -71,6 +72,12 @@ export interface AgentTranscriptProps {
71
72
  * ("about A$0.34"). The package never converts currency itself,
72
73
  * and a zero-cost run (a local model) shows no cost regardless. */
73
74
  formatCost?: (usd: number) => string;
75
+ /** Drawn in place of the package's own "working" line while the answer has
76
+ * shown nothing yet; given the line's `label` and `since`. */
77
+ working?: Snippet<[{
78
+ label?: string;
79
+ since?: number;
80
+ }]>;
74
81
  }
75
82
  declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
76
83
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -120,8 +120,13 @@
120
120
 
121
121
  // Who the question is put to: every collection the person may read, or one
122
122
  // of them. A person with one collection (or none) has nothing to pick, so
123
- // nothing renders rather than a control that only reselects itself.
124
- const hasChoice = $derived(collections.length > 1);
123
+ // nothing renders rather than a control that only reselects itself, unless
124
+ // the question is already narrowed to the one held: a page that has not yet
125
+ // had the list shows the collection its address names, and the control stays
126
+ // put as the rest of the list arrives.
127
+ const hasChoice = $derived(
128
+ collections.length > 1 || (collection !== undefined && collections.length > 0)
129
+ );
125
130
 
126
131
  let box = $state<HTMLElement | null>(null);
127
132
  let wrapper = $state<HTMLElement | null>(null);
@@ -137,8 +142,10 @@
137
142
  const el = textarea;
138
143
  if (!el) return;
139
144
  el.style.height = 'auto';
140
- const line = parseFloat(getComputedStyle(el).lineHeight) || 24;
141
- el.style.height = `${Math.min(el.scrollHeight, line * 6 + 20)}px`;
145
+ const style = getComputedStyle(el);
146
+ const line = parseFloat(style.lineHeight) || 27;
147
+ const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
148
+ el.style.height = `${Math.min(el.scrollHeight, line * 6 + padding)}px`;
142
149
  }
143
150
 
144
151
  $effect(() => {
@@ -280,7 +287,7 @@
280
287
  bind:value
281
288
  oninput={grow}
282
289
  onkeydown={keydown}
283
- rows="1"
290
+ rows="3"
284
291
  disabled={waiting}
285
292
  placeholder={waiting ? words.answeringPlaceholder : words.askPlaceholder}
286
293
  class="ds-lib-input"
@@ -305,7 +312,7 @@
305
312
  disabled={running || files.length >= MAX_FILES}
306
313
  aria-label="Attach a file"
307
314
  >
308
- <PaperclipIcon size={16} />
315
+ <PaperclipIcon size={20} />
309
316
  </button>
310
317
  {/if}
311
318
 
@@ -318,7 +325,7 @@
318
325
  aria-label="Ask for a briefing"
319
326
  title="Ask for a briefing"
320
327
  >
321
- <FileTextIcon size={16} />
328
+ <FileTextIcon size={20} />
322
329
  </button>
323
330
  {/if}
324
331
 
@@ -339,7 +346,7 @@
339
346
  <span class="ds-lib-spacer"></span>
340
347
  {#if running}
341
348
  <button type="button" class="ds-lib-stop" onclick={onstop} aria-label="Stop">
342
- <SquareIcon size={14} fill="currentColor" />
349
+ <SquareIcon size={16} fill="currentColor" />
343
350
  </button>
344
351
  {/if}
345
352
  {#if !waiting}
@@ -350,7 +357,7 @@
350
357
  disabled={!value.trim()}
351
358
  aria-label="Send"
352
359
  >
353
- <ArrowUpIcon size={16} />
360
+ <ArrowUpIcon size={20} />
354
361
  </button>
355
362
  {/if}
356
363
  </div>
@@ -441,10 +448,14 @@
441
448
  resize: none;
442
449
  border: 0;
443
450
  background: transparent;
444
- padding: 0.75rem 1rem 0.5rem;
451
+ padding: var(--ds-spacing-md) var(--ds-spacing-md) var(--ds-spacing-sm);
445
452
  color: var(--ds-color-foreground);
446
453
  font: inherit;
447
- font-size: 1rem;
454
+ /* No token carries a body size or a control size, so these are raw:
455
+ a resting box that reads as a chat composer on every page (an app
456
+ overriding them is a defect here). Three lines, 18px text. */
457
+ font-size: 1.125rem;
458
+ line-height: 1.5;
448
459
  outline: none;
449
460
  }
450
461
 
@@ -462,7 +473,7 @@
462
473
  align-items: center;
463
474
  gap: 0.5rem;
464
475
  row-gap: 0.375rem;
465
- padding: 0 0.75rem 0.625rem;
476
+ padding: 0 var(--ds-spacing-sm) var(--ds-spacing-sm);
466
477
  }
467
478
 
468
479
  .ds-lib-picker {
@@ -473,8 +484,8 @@
473
484
  .ds-lib-stop,
474
485
  .ds-lib-send {
475
486
  display: flex;
476
- width: 2rem;
477
- height: 2rem;
487
+ width: 2.75rem;
488
+ height: 2.75rem;
478
489
  flex: none;
479
490
  align-items: center;
480
491
  justify-content: center;
@@ -292,10 +292,11 @@
292
292
  border: 1px solid transparent;
293
293
  border-radius: var(--ds-radius-full);
294
294
  background: none;
295
- padding: 0.25rem 0.5rem 0.25rem 0.625rem;
295
+ min-height: 2.75rem;
296
+ padding: 0 var(--ds-spacing-sm) 0 0.75rem;
296
297
  color: var(--ds-color-muted-foreground);
297
298
  font: inherit;
298
- font-size: var(--ds-text-2xs);
299
+ font-size: 0.875rem;
299
300
  cursor: pointer;
300
301
  transition:
301
302
  color 150ms ease,
@@ -137,6 +137,9 @@
137
137
  * transcript. It stays put while the transcript scrolls and narrows
138
138
  * with it when a pane opens. */
139
139
  toolbar?: Snippet;
140
+ /** Drawn in place of each answer's "working" line before it shows anything
141
+ * (`AgentTranscriptProps.working`). */
142
+ working?: AgentTranscriptProps['working'];
140
143
  }
141
144
 
142
145
  let {
@@ -168,7 +171,8 @@
168
171
  turn: presentTurn,
169
172
  lead,
170
173
  toolbar,
171
- formatCost
174
+ formatCost,
175
+ working
172
176
  }: Props = $props();
173
177
 
174
178
  // Resolved ONCE, here, and handed down whole: every child takes `copy` and
@@ -392,6 +396,7 @@
392
396
  ? (text: string) => onedit(index, text)
393
397
  : undefined,
394
398
  formatCost,
399
+ working,
395
400
  oncite: oncite || loadDocument ? cite : undefined,
396
401
  onregenerate: last && !running ? onregenerate : undefined,
397
402
  onmark:
@@ -96,6 +96,9 @@ interface Props {
96
96
  * transcript. It stays put while the transcript scrolls and narrows
97
97
  * with it when a pane opens. */
98
98
  toolbar?: Snippet;
99
+ /** Drawn in place of each answer's "working" line before it shows anything
100
+ * (`AgentTranscriptProps.working`). */
101
+ working?: AgentTranscriptProps['working'];
99
102
  }
100
103
  declare const Conversation: import("svelte").Component<Props, {}, "">;
101
104
  type Conversation = ReturnType<typeof Conversation>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.10.6",
3
+ "version": "2026.10.7",
4
4
  "description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
5
5
  "type": "module",
6
6
  "license": "MIT",