@poodle64/librarian 2026.10.3 → 2026.10.5

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 (54) hide show
  1. package/README.md +12 -4
  2. package/dist/chat.svelte.d.ts +40 -0
  3. package/dist/chat.svelte.js +57 -3
  4. package/dist/client.d.ts +3 -0
  5. package/dist/client.js +4 -1
  6. package/dist/components/activity-group/activity-group.svelte +72 -77
  7. package/dist/components/activity-group/activity-group.svelte.d.ts +6 -1
  8. package/dist/components/agent-transcript/agent-transcript.svelte +225 -9
  9. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +19 -0
  10. package/dist/components/artefact-pane/artefact-pane.svelte +387 -43
  11. package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
  12. package/dist/components/conversation/conversation.svelte +74 -4
  13. package/dist/components/conversation/conversation.svelte.d.ts +15 -0
  14. package/dist/components/markdown/markdown.d.ts +30 -2
  15. package/dist/components/markdown/markdown.js +142 -5
  16. package/dist/components/markdown/markdown.svelte +130 -16
  17. package/dist/components/markdown/markdown.svelte.d.ts +12 -0
  18. package/dist/components/source-fold/index.d.ts +2 -0
  19. package/dist/components/source-fold/index.js +2 -0
  20. package/dist/components/source-fold/source-fold.svelte +225 -0
  21. package/dist/components/source-fold/source-fold.svelte.d.ts +17 -0
  22. package/dist/components/source-list/source-list.svelte +5 -3
  23. package/dist/components/source-list/source-list.svelte.d.ts +2 -0
  24. package/dist/components/thinking-row/thinking-row.svelte +38 -16
  25. package/dist/components/thinking-row/thinking-row.svelte.d.ts +3 -0
  26. package/dist/components/tool-row/tool-row.svelte +161 -49
  27. package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
  28. package/dist/components/visual-block/entity-card.svelte +94 -0
  29. package/dist/components/visual-block/entity-card.svelte.d.ts +7 -0
  30. package/dist/components/visual-block/timeline.svelte +99 -0
  31. package/dist/components/visual-block/timeline.svelte.d.ts +7 -0
  32. package/dist/components/visual-block/vega-chart.svelte +110 -0
  33. package/dist/components/visual-block/vega-chart.svelte.d.ts +7 -0
  34. package/dist/components/visual-block/visual-block.svelte +53 -0
  35. package/dist/components/visual-block/visual-block.svelte.d.ts +13 -0
  36. package/dist/components/written-card/index.d.ts +2 -0
  37. package/dist/components/written-card/index.js +2 -0
  38. package/dist/components/written-card/written-card.svelte +178 -0
  39. package/dist/components/written-card/written-card.svelte.d.ts +15 -0
  40. package/dist/copy.d.ts +73 -0
  41. package/dist/copy.js +56 -0
  42. package/dist/diff.d.ts +8 -0
  43. package/dist/diff.js +37 -0
  44. package/dist/steps.d.ts +28 -0
  45. package/dist/steps.js +168 -0
  46. package/dist/transcript.svelte.d.ts +19 -34
  47. package/dist/transcript.svelte.js +64 -56
  48. package/dist/visuals.d.ts +38 -0
  49. package/dist/visuals.js +82 -0
  50. package/dist/web-sources.d.ts +34 -0
  51. package/dist/web-sources.js +90 -0
  52. package/dist/written.d.ts +71 -0
  53. package/dist/written.js +83 -0
  54. package/package.json +14 -1
@@ -1,5 +1,10 @@
1
1
  <!--
2
- An artefact that is its answer's own prose, open in the reading column.
2
+ An artefact that is its answer's own prose, or one version of a document the
3
+ persona wrote, open in the reading column.
4
+
5
+ A written document is shown from its source: Markdown through the package's
6
+ sanitising renderer, HTML only inside a sandboxed frame with no permissions
7
+ (no scripts, no same origin), never injected into the page.
3
8
 
4
9
  The same shell shape `DocumentPane` uses (a column at 64rem, a bottom sheet
5
10
  below it) because decision 2 of the agent surface is that a colleague never
@@ -7,15 +12,40 @@
7
12
  only ever one, in the same place, closing whichever was there.
8
13
  -->
9
14
  <script lang="ts">
15
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
16
+ import CopyIcon from '@lucide/svelte/icons/copy';
17
+ import CheckIcon from '@lucide/svelte/icons/check';
18
+ import DownloadIcon from '@lucide/svelte/icons/download';
10
19
  import XIcon from '@lucide/svelte/icons/x';
20
+ import { untrack } from 'svelte';
11
21
  import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
12
22
  import { resolveCitations, splitSources, type Citation } from '../../citations';
13
- import { resolveCopy, type ChatCopy } from '../../copy';
23
+ import { fill, resolveCopy, type ChatCopy } from '../../copy';
24
+ import {
25
+ downloads,
26
+ framed,
27
+ isHtml,
28
+ kindLabel,
29
+ stripFrontMatter,
30
+ writtenKey,
31
+ type LoadWritten,
32
+ type Written,
33
+ type WrittenUrl
34
+ } from '../../written';
14
35
  import Markdown from '../markdown/markdown.svelte';
15
36
  import SourceList from '../source-list/source-list.svelte';
16
37
 
38
+
17
39
  interface Props {
18
- turn: Turn;
40
+ /** The turn whose answer is the artefact. Absent when `versions` is given. */
41
+ turn?: Turn;
42
+ /** Every version of one written document, oldest first: the pane shows
43
+ * `version` of them, with a switcher. */
44
+ versions?: Written[];
45
+ version?: number;
46
+ onversion?: (version: number) => void;
47
+ loadWritten?: LoadWritten;
48
+ writtenUrl?: WrittenUrl;
19
49
  onclose: () => void;
20
50
  /** Opens the cited document instead — swapping the column back, never
21
51
  * stacking it beside the artefact. Omit and the sources still list, but
@@ -24,14 +54,99 @@
24
54
  copy?: Partial<ChatCopy>;
25
55
  }
26
56
 
27
- let { turn, onclose, oncite, copy }: Props = $props();
57
+ let {
58
+ turn,
59
+ versions = [],
60
+ version,
61
+ onversion,
62
+ loadWritten,
63
+ writtenUrl,
64
+ onclose,
65
+ oncite,
66
+ copy
67
+ }: Props = $props();
28
68
 
29
69
  const words = $derived(resolveCopy(copy));
30
70
 
31
- const texts = $derived(segment(turn.blocks).filter((s): s is TextBlock => s.kind === 'text'));
71
+ // The newest, until the reader picks another.
72
+ const current = $derived(versions.find((v) => v.version === version) ?? versions.at(-1));
73
+ const currentKey = $derived(current ? writtenKey(current) : '');
74
+ // The one before it, whose blocks this one is marked against.
75
+ const before = $derived(current ? versions.filter((v) => v.version < current.version).at(-1) : undefined);
76
+ const beforeKey = $derived(before ? writtenKey(before) : '');
77
+ let source = $state<{ text: string; mediaType: string } | null>(null);
78
+ let earlier = $state<string | null>(null);
79
+ let failed = $state(false);
80
+
81
+ // Keyed on the version alone: a new version of the same name arriving in
82
+ // the stream must not read this one again.
83
+ $effect(() => {
84
+ if (!currentKey) return;
85
+ const shown = untrack(() => current);
86
+ const load = untrack(() => loadWritten);
87
+ if (!shown || !load) return;
88
+ let live = true;
89
+ source = null;
90
+ failed = false;
91
+ load(shown.name, shown.version).then(
92
+ (read) => live && (source = read),
93
+ () => live && (failed = true)
94
+ );
95
+ return () => (live = false);
96
+ });
97
+
98
+ $effect(() => {
99
+ earlier = null;
100
+ if (!beforeKey) return;
101
+ const prior = untrack(() => before);
102
+ const load = untrack(() => loadWritten);
103
+ if (!prior || !load || isHtml(prior.media_type)) return;
104
+ let live = true;
105
+ // A version that cannot be read is no reason to mark nothing else.
106
+ load(prior.name, prior.version).then(
107
+ (read) => live && (earlier = stripFrontMatter(read.text)),
108
+ () => {}
109
+ );
110
+ return () => (live = false);
111
+ });
112
+
113
+ const texts = $derived(
114
+ segment(turn?.blocks ?? []).filter((s): s is TextBlock => s.kind === 'text')
115
+ );
32
116
  const rawAnswer = $derived(texts.map((t) => t.text).join('\n\n'));
33
117
  const split = $derived(splitSources(rawAnswer));
34
- const sources = $derived(resolveCitations(turn.citations ?? [], split.citations));
118
+ const sources = $derived(resolveCitations(turn?.citations ?? [], split.citations));
119
+
120
+ const links = $derived(current && writtenUrl ? downloads(current, words) : []);
121
+ // The one download the bar offers directly: Word where there is one, else the source.
122
+ const primary = $derived(links.find((link) => link.format === 'docx') ?? links[0]);
123
+ const sub = $derived.by(() => {
124
+ if (!current) return '';
125
+ const fresh = current.at !== undefined && Date.now() - current.at < 120_000;
126
+ const when = fresh
127
+ ? fill(current.version > 1 ? words.paneEdited : words.paneWritten, { when: words.justNow })
128
+ : '';
129
+ return [kindLabel(current, words), when].filter(Boolean).join(' · ');
130
+ });
131
+ const changed = $derived(
132
+ current?.changes && before
133
+ ? fill(words.changedSince, { n: before.version, changes: current.changes })
134
+ : ''
135
+ );
136
+
137
+ let menu = $state(false);
138
+ let copied = $state(false);
139
+
140
+ async function copyDocument() {
141
+ if (!source) return;
142
+ try {
143
+ await navigator.clipboard.writeText(stripFrontMatter(source.text));
144
+ copied = true;
145
+ setTimeout(() => (copied = false), 1600);
146
+ } catch {
147
+ // A denied clipboard is not worth an error state on text the reader can select.
148
+ }
149
+ }
35
150
 
36
151
  let closeButton = $state<HTMLButtonElement | null>(null);
37
152
 
@@ -44,28 +159,22 @@
44
159
  });
45
160
 
46
161
  function keydown(event: KeyboardEvent) {
47
- if (event.key === 'Escape') {
48
- event.stopPropagation();
49
- onclose();
50
- }
162
+ if (event.key !== 'Escape') return;
163
+ event.stopPropagation();
164
+ if (menu) menu = false;
165
+ else onclose();
51
166
  }
52
167
  </script>
53
168
 
54
169
  <svelte:window onkeydown={keydown} />
55
170
 
56
- <aside aria-label="Study artefact" class="ds-lib-pane">
171
+ <aside aria-label={current?.title ?? 'Study artefact'} class="ds-lib-pane">
57
172
  <!-- The sheet's grabber. On a phone this is an overlay a reader has to be
58
173
  able to see the top edge of; on a desktop it is a column, so there is
59
174
  nothing here to grab. -->
60
175
  <div class="ds-lib-pane-grabber" aria-hidden="true"><span></span></div>
61
176
 
62
177
  <header class="ds-lib-pane-header">
63
- <div class="ds-lib-pane-heading">
64
- <h2 class="ds-lib-pane-title">{turn.artefact?.title}</h2>
65
- {#if turn.artefact?.summary}
66
- <p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
67
- {/if}
68
- </div>
69
178
  <button
70
179
  bind:this={closeButton}
71
180
  type="button"
@@ -75,33 +184,112 @@
75
184
  >
76
185
  <XIcon size={16} />
77
186
  </button>
187
+ <div class="ds-lib-pane-heading">
188
+ {#if current}
189
+ <h2 class="ds-lib-pane-title">{current.title}</h2>
190
+ {#if sub}<p class="ds-lib-pane-subtitle">{sub}</p>{/if}
191
+ {:else}
192
+ <h2 class="ds-lib-pane-title">{turn?.artefact?.title}</h2>
193
+ {#if turn?.artefact?.summary}
194
+ <p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
195
+ {/if}
196
+ {/if}
197
+ </div>
198
+ {#if current}
199
+ <select
200
+ class="ds-lib-pane-versions"
201
+ aria-label={words.writtenVersions}
202
+ value={current.version}
203
+ onchange={(event) => onversion?.(Number(event.currentTarget.value))}
204
+ >
205
+ {#each versions as option (option.version)}
206
+ <option value={option.version}>{fill(words.writtenVersion, { n: option.version })}</option>
207
+ {/each}
208
+ </select>
209
+ {#if source && !failed}
210
+ <button
211
+ type="button"
212
+ class="ds-lib-pane-tool ds-lib-pane-copy"
213
+ onclick={copyDocument}
214
+ aria-label={copied ? words.copiedDocument : words.copyDocument}
215
+ >
216
+ {#if copied}<CheckIcon size={15} />{:else}<CopyIcon size={15} />{/if}
217
+ </button>
218
+ {/if}
219
+ {#if writtenUrl && links.length > 0}
220
+ <div class="ds-lib-pane-menu">
221
+ <button
222
+ type="button"
223
+ class="ds-lib-pane-tool"
224
+ aria-haspopup="menu"
225
+ aria-expanded={menu}
226
+ onclick={() => (menu = !menu)}
227
+ >
228
+ <DownloadIcon size={15} />{words.download}<ChevronDownIcon size={14} />
229
+ </button>
230
+ {#if menu}
231
+ <ul class="ds-lib-pane-links" role="menu" aria-label={words.download}>
232
+ {#each links as link (link.format)}
233
+ <li role="none">
234
+ <a
235
+ role="menuitem"
236
+ href={writtenUrl(current.name, current.version, link.format)}
237
+ onclick={() => (menu = false)}>{link.label}</a
238
+ >
239
+ </li>
240
+ {/each}
241
+ </ul>
242
+ {/if}
243
+ </div>
244
+ {#if primary}
245
+ <a
246
+ class="ds-lib-pane-tool ds-lib-pane-quick"
247
+ href={writtenUrl(current.name, current.version, primary.format)}
248
+ >
249
+ <DownloadIcon size={15} />{primary.label}
250
+ </a>
251
+ {/if}
252
+ {/if}
253
+ {/if}
78
254
  </header>
79
255
 
80
- <div class="ds-lib-pane-body">
81
- <Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
256
+ {#if current}
257
+ {#if changed}<p class="ds-lib-pane-changed">{changed}</p>{/if}
258
+ {@const html = source !== null && isHtml(source.mediaType || current.media_type)}
259
+ <div class="ds-lib-pane-body" class:is-frame={html}>
260
+ {#if failed}
261
+ <p class="ds-lib-pane-note">{words.documentUnavailable}</p>
262
+ {:else if source && html}
263
+ <iframe class="ds-lib-pane-frame" sandbox="" srcdoc={framed(source.text)} title={current.title}
264
+ ></iframe>
265
+ {:else if source}
266
+ <div class="ds-lib-pane-paper">
267
+ <Markdown content={stripFrontMatter(source.text)} previous={earlier ?? undefined} />
268
+ </div>
269
+ {/if}
270
+ </div>
271
+ {:else}
272
+ <div class="ds-lib-pane-body">
273
+ <Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
82
274
 
83
- {#if sources.length > 0}
84
- <div class="ds-lib-pane-sources">
85
- <SourceList {sources} {words} {oncite} level={3} />
86
- </div>
87
- {/if}
88
- </div>
275
+ {#if sources.length > 0}
276
+ <div class="ds-lib-pane-sources">
277
+ <SourceList {sources} {words} {oncite} level={3} />
278
+ </div>
279
+ {/if}
280
+ </div>
281
+ {/if}
89
282
  </aside>
90
283
 
91
284
  <style>
92
285
  .ds-lib-pane {
93
286
  position: fixed;
94
- inset-inline: 0;
95
- bottom: 0;
287
+ inset: 0;
96
288
  z-index: 40;
97
289
  display: flex;
98
- height: 80svh;
290
+ height: 100dvh;
99
291
  flex-direction: column;
100
- border-top: 1px solid var(--ds-color-border);
101
- border-start-start-radius: var(--ds-radius-xl);
102
- border-start-end-radius: var(--ds-radius-xl);
103
292
  background: var(--ds-color-surface-1);
104
- box-shadow: 0 -12px 40px rgb(0 0 0 / 0.25);
105
293
  }
106
294
 
107
295
  .ds-lib-pane-grabber {
@@ -118,17 +306,19 @@
118
306
  background: var(--ds-color-border);
119
307
  }
120
308
 
309
+ /* A phone's sheet leads with Close; the column ends with it. */
121
310
  .ds-lib-pane-header {
122
311
  display: flex;
123
- align-items: flex-start;
312
+ align-items: center;
124
313
  gap: 0.5rem;
125
314
  border-bottom: 1px solid var(--ds-color-border);
126
- padding: 0.75rem 1rem;
315
+ padding: 0.5rem 1rem 0.75rem;
127
316
  }
128
317
 
129
318
  .ds-lib-pane-heading {
130
319
  min-width: 0;
131
320
  flex: 1;
321
+ order: 1;
132
322
  }
133
323
 
134
324
  .ds-lib-pane-title,
@@ -142,23 +332,24 @@
142
332
  .ds-lib-pane-title {
143
333
  color: var(--ds-color-foreground);
144
334
  font-family: inherit;
145
- font-size: 0.875rem;
335
+ font-size: 0.9375rem;
146
336
  font-weight: 600;
147
337
  }
148
338
 
149
339
  .ds-lib-pane-subtitle {
340
+ display: none;
150
341
  color: var(--ds-color-muted-foreground);
151
342
  font-size: var(--ds-text-2xs);
152
343
  }
153
344
 
154
345
  .ds-lib-pane-close {
155
346
  display: flex;
156
- width: 2rem;
157
- height: 2rem;
347
+ width: 2.5rem;
348
+ height: 2.5rem;
158
349
  flex: none;
159
350
  align-items: center;
160
351
  justify-content: center;
161
- margin-top: -0.25rem;
352
+ order: 0;
162
353
  border: 0;
163
354
  border-radius: var(--ds-radius-lg);
164
355
  background: none;
@@ -174,11 +365,109 @@
174
365
  color: var(--ds-color-foreground);
175
366
  }
176
367
 
177
- .ds-lib-pane-close:focus-visible {
368
+ .ds-lib-pane-close:focus-visible,
369
+ .ds-lib-pane-tool:focus-visible,
370
+ .ds-lib-pane-versions:focus-visible {
178
371
  outline: 2px solid var(--ds-color-ring);
179
372
  outline-offset: 2px;
180
373
  }
181
374
 
375
+ .ds-lib-pane-versions {
376
+ order: 2;
377
+ border: 1px solid var(--ds-color-border);
378
+ border-radius: var(--ds-radius-md);
379
+ background: var(--ds-color-surface-1);
380
+ padding: 0.375rem 0.5rem;
381
+ color: var(--ds-color-foreground);
382
+ font: inherit;
383
+ font-size: 0.8125rem;
384
+ }
385
+
386
+ .ds-lib-pane-tool {
387
+ display: inline-flex;
388
+ min-height: 2.25rem;
389
+ align-items: center;
390
+ gap: 0.375rem;
391
+ border: 1px solid var(--ds-color-border);
392
+ border-radius: var(--ds-radius-md);
393
+ background: none;
394
+ padding: 0.25rem 0.625rem;
395
+ color: var(--ds-color-foreground);
396
+ font: inherit;
397
+ font-size: 0.8125rem;
398
+ text-decoration: none;
399
+ cursor: pointer;
400
+ }
401
+
402
+ .ds-lib-pane-tool:hover {
403
+ background: var(--ds-color-surface-2);
404
+ }
405
+
406
+ .ds-lib-pane-copy {
407
+ display: none;
408
+ order: 3;
409
+ }
410
+
411
+ .ds-lib-pane-menu {
412
+ position: relative;
413
+ display: none;
414
+ order: 4;
415
+ }
416
+
417
+ .ds-lib-pane-quick {
418
+ order: 3;
419
+ }
420
+
421
+ .ds-lib-pane-links {
422
+ position: absolute;
423
+ inset-block-start: calc(100% + 0.25rem);
424
+ inset-inline-end: 0;
425
+ z-index: 2;
426
+ display: flex;
427
+ min-width: 9rem;
428
+ flex-direction: column;
429
+ margin: 0;
430
+ border: 1px solid var(--ds-color-border);
431
+ border-radius: var(--ds-radius-md);
432
+ background: var(--ds-color-surface-1);
433
+ padding: 0.25rem;
434
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
435
+ list-style: none;
436
+ }
437
+
438
+ .ds-lib-pane-links a {
439
+ display: block;
440
+ border-radius: var(--ds-radius-sm);
441
+ padding: 0.5rem 0.625rem;
442
+ color: var(--ds-color-foreground);
443
+ font-size: 0.8125rem;
444
+ text-decoration: none;
445
+ }
446
+
447
+ .ds-lib-pane-links a:hover,
448
+ .ds-lib-pane-links a:focus-visible {
449
+ background: var(--ds-color-surface-2);
450
+ }
451
+
452
+ .ds-lib-pane-changed {
453
+ display: flex;
454
+ align-items: center;
455
+ gap: 0.5rem;
456
+ margin: 0;
457
+ border-bottom: 1px solid var(--ds-color-border);
458
+ padding: 0.5rem 1rem;
459
+ color: var(--ds-color-muted-foreground);
460
+ font-size: 0.8125rem;
461
+ }
462
+
463
+ .ds-lib-pane-changed::before {
464
+ content: '';
465
+ width: 2px;
466
+ height: 0.875rem;
467
+ flex: none;
468
+ background: var(--ds-color-foreground);
469
+ }
470
+
182
471
  .ds-lib-pane-body {
183
472
  min-height: 0;
184
473
  flex: 1;
@@ -187,6 +476,39 @@
187
476
  padding: 0.75rem 1rem;
188
477
  }
189
478
 
479
+ .ds-lib-pane-note {
480
+ margin: 0;
481
+ color: var(--ds-color-muted-foreground);
482
+ font-size: var(--ds-text-2xs);
483
+ }
484
+
485
+ /* The document is paper, light in either theme: its own ink and rules
486
+ replace the app's, so a dark page does not paint it grey on grey. */
487
+ .ds-lib-pane-paper {
488
+ --ds-color-foreground: var(--ds-paper-ink, #1b1c1e);
489
+ --ds-color-muted-foreground: var(--ds-paper-muted, #5f6368);
490
+ --ds-color-border: var(--ds-paper-rule, #c4c7cc);
491
+ --ds-color-surface-1: var(--ds-paper-shade, #dcdee2);
492
+ --ds-color-primary-ink: var(--ds-paper-ink, #1b1c1e);
493
+ border-radius: var(--ds-radius-sm);
494
+ background: var(--ds-paper, #e9eaec);
495
+ padding: 1.25rem 1.5rem;
496
+ color: var(--ds-color-foreground);
497
+ }
498
+
499
+ .ds-lib-pane-body.is-frame {
500
+ overflow: hidden;
501
+ padding: 0;
502
+ }
503
+
504
+ .ds-lib-pane-frame {
505
+ display: block;
506
+ width: 100%;
507
+ height: 100%;
508
+ border: 0;
509
+ background: #fff;
510
+ }
511
+
190
512
  .ds-lib-pane-sources {
191
513
  margin-top: 1.5rem;
192
514
  }
@@ -196,16 +518,38 @@
196
518
  position: relative;
197
519
  inset: auto;
198
520
  height: auto;
199
- width: 35rem;
521
+ width: 42rem;
200
522
  flex: none;
201
- border-top: 0;
202
523
  border-inline-start: 1px solid var(--ds-color-border);
203
- border-radius: 0;
204
- box-shadow: none;
205
524
  }
206
525
 
207
526
  .ds-lib-pane-grabber {
208
527
  display: none;
209
528
  }
529
+
530
+ .ds-lib-pane-header {
531
+ padding-top: 0.75rem;
532
+ }
533
+
534
+ .ds-lib-pane-heading {
535
+ order: 0;
536
+ }
537
+
538
+ .ds-lib-pane-subtitle {
539
+ display: block;
540
+ }
541
+
542
+ .ds-lib-pane-copy,
543
+ .ds-lib-pane-menu {
544
+ display: inline-flex;
545
+ }
546
+
547
+ .ds-lib-pane-quick {
548
+ display: none;
549
+ }
550
+
551
+ .ds-lib-pane-close {
552
+ order: 5;
553
+ }
210
554
  }
211
555
  </style>
@@ -1,8 +1,17 @@
1
1
  import { type Turn } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
3
  import { type ChatCopy } from '../../copy';
4
+ import { type LoadWritten, type Written, type WrittenUrl } from '../../written';
4
5
  interface Props {
5
- turn: Turn;
6
+ /** The turn whose answer is the artefact. Absent when `versions` is given. */
7
+ turn?: Turn;
8
+ /** Every version of one written document, oldest first: the pane shows
9
+ * `version` of them, with a switcher. */
10
+ versions?: Written[];
11
+ version?: number;
12
+ onversion?: (version: number) => void;
13
+ loadWritten?: LoadWritten;
14
+ writtenUrl?: WrittenUrl;
6
15
  onclose: () => void;
7
16
  /** Opens the cited document instead — swapping the column back, never
8
17
  * stacking it beside the artefact. Omit and the sources still list, but
@@ -20,19 +20,32 @@
20
20
  import type { DescribeTool, Turn } from '../../transcript.svelte';
21
21
  import type { Verdict } from '../../chat.svelte';
22
22
  import type { Citation, LoadDocument } from '../../citations';
23
+ import {
24
+ writtenKey,
25
+ writtenVersions,
26
+ type LoadWritten,
27
+ type Written,
28
+ type WrittenUrl
29
+ } from '../../written';
23
30
  import { DEFAULT_PERSONA, personaName, resolveCopy, type ChatCopy } from '../../copy';
24
31
  import { FollowScroll } from '../../follow-scroll.svelte';
25
32
  import AgentTranscript, {
26
33
  type AgentTranscriptProps
27
34
  } from '../agent-transcript/agent-transcript.svelte';
28
35
  import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
36
+ import type { Visual } from '../../visuals';
29
37
  import DocumentPane from '../document-pane/document-pane.svelte';
30
38
  import ScopeStatement from '../scope-statement/scope-statement.svelte';
31
39
 
32
- /** What the reading column holds — a cited document or a study artefact,
33
- * never both and never two panes: whichever the reader last opened
34
- * replaces whatever was there. */
35
- type Column = { kind: 'document'; citation: Citation } | { kind: 'artefact'; turn: Turn } | null;
40
+ /** What the reading column holds — a cited document, a study artefact or a
41
+ * document the persona wrote, never two at once and never two panes:
42
+ * whichever the reader last opened replaces whatever was there. A written
43
+ * document's `version` is null while the reader follows the newest. */
44
+ type Column =
45
+ | { kind: 'document'; citation: Citation }
46
+ | { kind: 'artefact'; turn: Turn }
47
+ | { kind: 'written'; name: string; version: number | null }
48
+ | null;
36
49
 
37
50
  interface Props {
38
51
  turns: Turn[];
@@ -86,6 +99,19 @@
86
99
  showing?: string;
87
100
  /** The persona's own words for its tools, and what they read. */
88
101
  describeTool?: DescribeTool;
102
+ /** Replaces the question at `position` (its place among `turns`) with
103
+ * `text` and asks it again from there (`Chat.edit`). Offered on every
104
+ * plain question while no answer is being written; without it no turn
105
+ * can be edited. */
106
+ onedit?: (position: number, text: string) => Promise<boolean>;
107
+ /** Reads one version of a document the persona wrote, to show it in the
108
+ * column. Without it its cards link but do not open. */
109
+ loadWritten?: LoadWritten;
110
+ /** Where a version downloads. Without it no download links render. */
111
+ writtenUrl?: WrittenUrl;
112
+ /** Renderers by a fence's language (`defaultVisuals`, and the app's own):
113
+ * a block of one is drawn by its component rather than as code. */
114
+ visuals?: Record<string, Visual>;
89
115
  /** Prices a turn's `outcome.costUsd` in the host's own currency and
90
116
  * words ("about A$0.34"), for the footer's cost line beside a turn's
91
117
  * duration. The package never converts currency itself; omitted, the
@@ -133,6 +159,10 @@
133
159
  onopenartefact,
134
160
  showing,
135
161
  describeTool,
162
+ onedit,
163
+ loadWritten,
164
+ writtenUrl,
165
+ visuals,
136
166
  collectionNames = new Set(),
137
167
  composer,
138
168
  turn: presentTurn,
@@ -252,6 +282,24 @@
252
282
  return undefined;
253
283
  }
254
284
 
285
+ /** Opens a written version, following the newest if it is the newest. */
286
+ function openWritten(written: Written) {
287
+ const newest = writtenVersions(turns, written.name).at(-1)?.version;
288
+ column = {
289
+ kind: 'written',
290
+ name: written.name,
291
+ version: written.version === newest ? null : written.version
292
+ };
293
+ }
294
+
295
+ const shownWritten = $derived.by(() => {
296
+ const open = column;
297
+ if (open?.kind !== 'written') return undefined;
298
+ const versions = writtenVersions(turns, open.name);
299
+ const shown = versions.find((v) => v.version === open.version) ?? versions.at(-1);
300
+ return { versions, version: shown?.version, key: shown ? writtenKey(shown) : undefined };
301
+ });
302
+
255
303
  function isShowing(turn: Turn): boolean {
256
304
  if (onopenartefact) return showing === turn.id;
257
305
  return column?.kind === 'artefact' && column.turn.id === turn.id;
@@ -333,6 +381,16 @@
333
381
  artefactOpen: isShowing(turn),
334
382
  onopenartefact: opener(turn),
335
383
  describeTool,
384
+ files: turn.files,
385
+ written: turn.written,
386
+ showingWritten: shownWritten?.key,
387
+ onopenwritten: loadWritten ? openWritten : undefined,
388
+ writtenUrl,
389
+ visuals,
390
+ onedit:
391
+ onedit && !running && !turn.kind && turn.question
392
+ ? (text: string) => onedit(index, text)
393
+ : undefined,
336
394
  formatCost,
337
395
  oncite: oncite || loadDocument ? cite : undefined,
338
396
  onregenerate: last && !running ? onregenerate : undefined,
@@ -387,6 +445,18 @@
387
445
  copy={words}
388
446
  onclose={() => (column = null)}
389
447
  />
448
+ {:else if column?.kind === 'written' && shownWritten && shownWritten.versions.length > 0}
449
+ <ArtefactPane
450
+ versions={shownWritten.versions}
451
+ version={shownWritten.version}
452
+ onversion={(version) => {
453
+ if (column?.kind === 'written') column = { ...column, version };
454
+ }}
455
+ {loadWritten}
456
+ {writtenUrl}
457
+ copy={words}
458
+ onclose={() => (column = null)}
459
+ />
390
460
  {:else if column?.kind === 'artefact'}
391
461
  <ArtefactPane
392
462
  turn={column.turn}