@poodle64/librarian 2026.10.2 → 2026.10.4

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 (40) hide show
  1. package/README.md +10 -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 +21 -108
  7. package/dist/components/activity-group/activity-group.svelte.d.ts +3 -1
  8. package/dist/components/agent-transcript/agent-transcript.svelte +164 -4
  9. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +18 -0
  10. package/dist/components/artefact-pane/artefact-pane.svelte +162 -18
  11. package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
  12. package/dist/components/conversation/conversation.svelte +83 -6
  13. package/dist/components/conversation/conversation.svelte.d.ts +15 -0
  14. package/dist/components/fair-use-notice/fair-use-notice.svelte +1 -1
  15. package/dist/components/markdown/markdown.d.ts +11 -0
  16. package/dist/components/markdown/markdown.js +62 -2
  17. package/dist/components/markdown/markdown.svelte +24 -3
  18. package/dist/components/markdown/markdown.svelte.d.ts +4 -0
  19. package/dist/components/scope-statement/scope-statement.svelte +17 -20
  20. package/dist/components/thinking-row/thinking-row.svelte +1 -0
  21. package/dist/components/thinking-row/thinking-row.svelte.d.ts +1 -0
  22. package/dist/components/tool-row/tool-row.svelte +40 -20
  23. package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
  24. package/dist/components/web-list/index.d.ts +2 -0
  25. package/dist/components/web-list/index.js +2 -0
  26. package/dist/components/web-list/web-list.svelte +84 -0
  27. package/dist/components/web-list/web-list.svelte.d.ts +9 -0
  28. package/dist/components/written-card/index.d.ts +2 -0
  29. package/dist/components/written-card/index.js +2 -0
  30. package/dist/components/written-card/written-card.svelte +130 -0
  31. package/dist/components/written-card/written-card.svelte.d.ts +15 -0
  32. package/dist/copy.d.ts +19 -0
  33. package/dist/copy.js +13 -0
  34. package/dist/transcript.svelte.d.ts +15 -34
  35. package/dist/transcript.svelte.js +52 -55
  36. package/dist/web-sources.d.ts +18 -0
  37. package/dist/web-sources.js +58 -0
  38. package/dist/written.d.ts +46 -0
  39. package/dist/written.js +41 -0
  40. package/package.json +5 -1
package/README.md CHANGED
@@ -34,11 +34,13 @@ src/lib/
34
34
  agent-transcript/ one question and everything the persona did answering it
35
35
  document-pane/ the cited document, open at the cited passage
36
36
  artefact-card/ an artefact's card, in the host's words
37
- artefact-pane/ an artefact that is its answer's prose, in the column
37
+ artefact-pane/ an artefact that is its answer's prose, or one version of a written document, in the column
38
+ written-card/ a document the persona wrote: version, Open, downloads
39
+ web-list/ the web pages an answer drew on, apart from its numbered sources
38
40
  composer/ the input box: attachments, one collection control, a note, send/stop
39
41
  markdown/ sanitised, streaming-safe markdown + highlighting
40
- activity-group/ a whole investigation, as one quiet line
41
- tool-row/ one tool call
42
+ activity-group/ what the persona did, one collapsed row per step
43
+ tool-row/ one tool call, opening on its input and result
42
44
  thinking-row/ one thought, or one line of between-tool narration
43
45
  working/ the pre-first-token "something is happening" indicator
44
46
  ```
@@ -394,10 +396,14 @@ end that way.
394
396
 
395
397
  | Concern | How |
396
398
  | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
- | The persona's tools | `describeTool(block) => ToolWords \| undefined`: the row (`verb`, `object`, `detail`), the activity line's counts (`tally`), and what it read (`source`). `undefined` leaves a call to the package's own words |
399
+ | The persona's tools | `describeTool(block) => ToolWords \| undefined`: the row (`verb`, `object`, `detail`) and what it read (`source`). `undefined` leaves a call to the package's own words |
398
400
  | What an answer read | `ToolWords.source` lists under the answer, under `copy.sources`; tapping one calls `oncite`. Nothing checked it, so it carries no trust mark |
399
401
  | An artefact | `turn.artefact = { title, summary }` cards under the prose once the turn settles; `isAnswer: true` is an artefact that IS the prose, and replaces it |
400
402
  | Opening it | `onopenartefact(turn)` and `showing` (the turn id the host shows). Without a handler, an `isAnswer` artefact opens in `ArtefactPane` and any other offers no action |
403
+ | Editing a question | `Conversation`'s `onedit(position, text)` (`Chat.edit`) offers an edit control on each plain question while nothing is being answered; the turns from there on are dropped and the new question streams in at that position (`ask({ edit })`). Files are not sent again |
404
+ | Documents it wrote | `artefact` frames and a stored turn's `artefacts` become `turn.written`, carded with Open and downloads. `loadWritten(name, version)` and `writtenUrl(name, version, format)` (`Chat.loadArtefact` / `Chat.artefactUrl` over the transport) let the pane show a version: Markdown sanitised, HTML only in a `sandbox=""` frame |
405
+ | Web sources | Pages from the turn's supacrawl calls and the answer's Markdown links list under `copy.fromTheWeb`, unnumbered, apart from the library's `[n]` sources |
406
+ | Drawing a fence | `fences={{ chart: Chart }}` on `Conversation` draws a fenced block of that language with the host's component (`source` is its text), once the answer has settled |
401
407
  | "Answered without a source" | `copy.notHeld: ''` for a persona that answers from no sources |
402
408
  | Saying something mid-run | `Composer`'s `sendWhileRunning`: while the session works the box stays open and Send sits beside Stop. For a route that reads a message while the run works; a chat leaves it off and waits for its answer |
403
409
 
@@ -22,6 +22,7 @@
22
22
  import type { AgentEvent } from './client';
23
23
  import type { Citation } from './citations';
24
24
  import { type Artefact, type Turn } from './transcript.svelte';
25
+ import type { Written } from './written';
25
26
  /** One conversation in the list. */
26
27
  export interface ConversationSummary {
27
28
  /** The agent's own session id: what resumes it, what names it in the address. */
@@ -47,6 +48,20 @@ export interface StoredTurn {
47
48
  collection?: string | null;
48
49
  /** Where this answer's read-only view lives while it is shared. */
49
50
  shared?: string | null;
51
+ /** The names of the files attached to the question. */
52
+ files?: string[];
53
+ /** The document versions written answering it, in order. */
54
+ artefacts?: Written[];
55
+ /** Every tool the agent called, in order, with its (truncated) result. */
56
+ steps?: StoredStep[];
57
+ }
58
+ /** One tool call of a stored turn. */
59
+ export interface StoredStep {
60
+ id: string;
61
+ name: string;
62
+ input: Record<string, unknown>;
63
+ result: string;
64
+ is_error: boolean;
50
65
  }
51
66
  /** A conversation reopened. */
52
67
  export interface ConversationRead extends ConversationSummary {
@@ -79,6 +94,8 @@ export interface AskRequest {
79
94
  kind?: string;
80
95
  /** The one collection to ask, by name: `client.ask()`'s `collection`. */
81
96
  collection?: string;
97
+ /** The position of the question this replaces: `client.ask()`'s `edit`. */
98
+ edit?: number;
82
99
  }
83
100
  /**
84
101
  * A persona's chat routes, as the host's own client reaches them. Every
@@ -114,6 +131,14 @@ export interface ChatTransport {
114
131
  * stops sharing it. Resolves to where it is read while shared, null once
115
132
  * it is not. */
116
133
  share?(id: string, turn: number, share: boolean): Promise<string | null>;
134
+ /** `GET conversations/{id}/artefact`, as `source`: one version of a document
135
+ * the agent wrote. */
136
+ loadArtefact?(id: string, name: string, version: number): Promise<{
137
+ text: string;
138
+ mediaType: string;
139
+ }>;
140
+ /** Where that version downloads as `format` (`source`, or one of its `formats`). */
141
+ artefactUrl?(id: string, name: string, version: number, format: string): string;
117
142
  }
118
143
  /** A job's routes. */
119
144
  export interface JobTransport {
@@ -188,6 +213,14 @@ export declare class Chat {
188
213
  /** The last question again, as a new turn: the conversation is append-only,
189
214
  * because the agent's transcript is. Same kind, same collection. */
190
215
  again(): Promise<boolean>;
216
+ /**
217
+ * Replace the question at `position` (its place among `turns`) with `text`:
218
+ * the turns from there on go from the page and from the server, and the new
219
+ * question streams in at that position. The files that went with the old
220
+ * one are not sent again. Same kind and collection as the one replaced.
221
+ * Resolves like `ask`; a refusal puts the dropped turns back.
222
+ */
223
+ edit(position: number, text: string): Promise<boolean>;
191
224
  /** Stop the answer being written, wherever it is being written. */
192
225
  stop(): Promise<void>;
193
226
  /** Read today's allowance again. One that cannot be read is left as it was. */
@@ -207,6 +240,13 @@ export declare class Chat {
207
240
  /** Share an answer the agent holds, or stop sharing it. False when that
208
241
  * was not recorded. */
209
242
  share(turn: Turn, share: boolean): Promise<boolean>;
243
+ /** One version of a document the agent wrote in the open conversation. */
244
+ loadArtefact(name: string, version: number): Promise<{
245
+ text: string;
246
+ mediaType: string;
247
+ }>;
248
+ /** Where that version downloads as `format`; empty with no conversation open. */
249
+ artefactUrl(name: string, version: number, format: string): string;
210
250
  }
211
251
  /** A turn as read back, ready for `Conversation`: given one and no handlers,
212
252
  * that is the read-only view of a shared answer. */
@@ -172,6 +172,33 @@ export class Chat {
172
172
  ? this.ask(last.question, last.kind, last.collection)
173
173
  : Promise.resolve(false);
174
174
  }
175
+ /**
176
+ * Replace the question at `position` (its place among `turns`) with `text`:
177
+ * the turns from there on go from the page and from the server, and the new
178
+ * question streams in at that position. The files that went with the old
179
+ * one are not sent again. Same kind and collection as the one replaced.
180
+ * Resolves like `ask`; a refusal puts the dropped turns back.
181
+ */
182
+ async edit(position, text) {
183
+ const body = text.trim();
184
+ const resume = this.conversationId;
185
+ const was = this.#turns[position];
186
+ if (!body || !resume || !was || this.#job || this.busy || this.quota?.reached)
187
+ return false;
188
+ const removed = this.#turns.splice(position);
189
+ const held = removed.filter((t) => this.#held.delete(t.id)).map((t) => t.id);
190
+ this.#version += 1;
191
+ const asked = await this.#ask(body, [], false, was.kind, was.collection, position);
192
+ // Refused before the server dropped anything: the page is as it was left.
193
+ // A conversation found already being answered was shown whole instead.
194
+ if (!asked && this.#turns.length === position) {
195
+ this.#turns.push(...removed);
196
+ for (const id of held)
197
+ this.#held.add(id);
198
+ this.#version += 1;
199
+ }
200
+ return asked;
201
+ }
175
202
  /** Stop the answer being written, wherever it is being written. */
176
203
  async stop() {
177
204
  const id = this.conversationId;
@@ -318,7 +345,7 @@ export class Chat {
318
345
  this.#running = false;
319
346
  this.answering = null;
320
347
  }
321
- async #ask(text, files, typed, kind, collection) {
348
+ async #ask(text, files, typed, kind, collection, edit) {
322
349
  const chat = this.#chat;
323
350
  const mine = this.#generation;
324
351
  const stream = { controller: new AbortController(), stopWanted: false };
@@ -330,6 +357,7 @@ export class Chat {
330
357
  blocks: [],
331
358
  outcome: null,
332
359
  citations: [],
360
+ files: files.map((f) => f.name),
333
361
  kind,
334
362
  collection,
335
363
  artefact: this.#card(kind)
@@ -346,7 +374,8 @@ export class Chat {
346
374
  resume: this.conversationId,
347
375
  signal: stream.controller.signal,
348
376
  kind,
349
- collection
377
+ collection,
378
+ edit
350
379
  })) {
351
380
  const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
352
381
  if (stream.stopWanted) {
@@ -415,6 +444,18 @@ export class Chat {
415
444
  this.#finish();
416
445
  return true;
417
446
  }
447
+ /** One version of a document the agent wrote in the open conversation. */
448
+ loadArtefact(name, version) {
449
+ const id = this.conversationId;
450
+ if (!id || !this.#chat?.loadArtefact)
451
+ return Promise.reject(new Error('no artefact'));
452
+ return this.#chat.loadArtefact(id, name, version);
453
+ }
454
+ /** Where that version downloads as `format`; empty with no conversation open. */
455
+ artefactUrl(name, version, format) {
456
+ const id = this.conversationId;
457
+ return id && this.#chat?.artefactUrl ? this.#chat.artefactUrl(id, name, version, format) : '';
458
+ }
418
459
  /** What a turn of `kind` opens, if the host says it opens anything. */
419
460
  #card(kind) {
420
461
  return kind ? this.#artefact?.(kind) : undefined;
@@ -547,13 +588,26 @@ export class Chat {
547
588
  /** A turn as read back, ready for `Conversation`: given one and no handlers,
548
589
  * that is the read-only view of a shared answer. */
549
590
  export function storedTurn(conversation, index, stored) {
591
+ const blocks = (stored.steps ?? []).map((step, at) => ({
592
+ kind: 'tool',
593
+ index: at,
594
+ id: step.id,
595
+ name: step.name,
596
+ rawInput: JSON.stringify(step.input ?? {}),
597
+ result: step.result,
598
+ isError: step.is_error
599
+ }));
600
+ if (stored.answer)
601
+ blocks.push({ kind: 'text', index: blocks.length, text: stored.answer });
550
602
  return {
551
603
  id: `${conversation}:${index}`,
552
604
  question: stored.question,
553
605
  at: epoch(stored.at) ?? undefined,
554
- blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
606
+ blocks,
555
607
  outcome: null,
556
608
  citations: stored.citations ?? [],
609
+ files: stored.files ?? [],
610
+ written: stored.artefacts ?? [],
557
611
  kind: stored.kind ?? undefined,
558
612
  collection: stored.collection ?? undefined,
559
613
  shared: stored.shared ?? undefined
package/dist/client.d.ts CHANGED
@@ -71,6 +71,9 @@ export interface AskOptions {
71
71
  /** The one collection this question is asked of, by name. Absent asks
72
72
  * across every collection the person may read. */
73
73
  collection?: string;
74
+ /** The position (0-based) of the person's question this one replaces: the
75
+ * server drops that turn and every one after it. Needs `resume`. */
76
+ edit?: number;
74
77
  /** Files the persona reads for this question. Switches the request to multipart. */
75
78
  files?: File[];
76
79
  /** What kind of turn this ask should become. Absent means an ordinary
package/dist/client.js CHANGED
@@ -29,7 +29,8 @@ function requestInit(options, signal) {
29
29
  question: options.question,
30
30
  resume: options.resume ?? null,
31
31
  collection: options.collection,
32
- kind: options.kind
32
+ kind: options.kind,
33
+ edit: options.edit
33
34
  }),
34
35
  signal
35
36
  };
@@ -44,6 +45,8 @@ function requestInit(options, signal) {
44
45
  form.append('files[]', file, file.name);
45
46
  if (options.kind)
46
47
  form.append('kind', options.kind);
48
+ if (options.edit !== undefined)
49
+ form.append('edit', String(options.edit));
47
50
  return { method: 'POST', credentials: 'include', body: form, signal };
48
51
  }
49
52
  /**
@@ -1,21 +1,19 @@
1
1
  <!--
2
- A whole investigation, as ONE line the reader can open.
2
+ What the persona did before it answered, one collapsed row per step, in
3
+ order — the way Claude Code's own panel lists them.
3
4
 
4
- Not a stack. Fifteen tool rows between the question and the first word of the
5
- answer is the machinery on show, and a running commentary of "Let me
6
- check…" is worse: it narrates a mechanism the persona is under instruction
7
- never to mention. So there is exactly one row in every state — "Working…"
8
- while it runs, a count of what was done once it settles — and the steps are
9
- behind a disclosure for the reader who wants them.
5
+ Each row says what the step did in a reader's words and opens on what went
6
+ in and what came back. A thinking block is a folded row too: "Thinking…"
7
+ while it streams, "Thought" once done.
10
8
 
11
- That commentary is a step in here too. It reaches the caller as an ordinary
12
- `text` block, not a `thinking` one, so `segment()` re-homes any text with a
13
- tool call still to come into this group; it renders as a ThinkingRow beside
14
- the tools, folded like the rest.
9
+ The persona's between-tool commentary is a step in here as well. It reaches
10
+ the caller as an ordinary `text` block, not a `thinking` one, so `segment()`
11
+ re-homes any text with a tool call still to come into this group; it renders
12
+ as a ThinkingRow beside the tools, folded like the rest.
15
13
  -->
16
14
  <script lang="ts">
17
- import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
18
- import { summariseActivity, type ActivityGroup } from '../../transcript.svelte';
15
+ import type { ActivityGroup } from '../../transcript.svelte';
16
+ import type { ChatCopy } from '../../copy';
19
17
  import ToolRow from '../tool-row/tool-row.svelte';
20
18
  import ThinkingRow from '../thinking-row/thinking-row.svelte';
21
19
 
@@ -23,111 +21,26 @@
23
21
  group: ActivityGroup;
24
22
  /** True while this group is the live one. */
25
23
  live: boolean;
24
+ copy?: Partial<ChatCopy>;
26
25
  }
27
26
 
28
- let { group, live }: Props = $props();
29
- let expanded = $state(false);
30
-
31
- // A live group has nothing to count yet, so counting it reads as "0
32
- // searches" flickering up to the real number.
33
- const label = $derived(live ? 'Working…' : summariseActivity(group));
27
+ let { group, live, copy }: Props = $props();
34
28
  </script>
35
29
 
36
- <div>
37
- <button
38
- type="button"
39
- class="ds-lib-activity-toggle"
40
- onclick={() => (expanded = !expanded)}
41
- aria-expanded={expanded}
42
- >
43
- <span class="ds-lib-activity-chevron" class:is-open={expanded}>
44
- <ChevronRightIcon size={14} />
45
- </span>
46
- {#if live}
47
- <span class="ds-lib-activity-dot"></span>
30
+ <div class="ds-lib-activity-steps">
31
+ {#each group.steps as step, i (step.block.index)}
32
+ {#if step.block.kind === 'tool'}
33
+ <ToolRow block={step.block} words={step.words} running={live} {copy} />
34
+ {:else}
35
+ <ThinkingRow block={step.block} active={live && i === group.steps.length - 1} />
48
36
  {/if}
49
- <span>{label}</span>
50
- </button>
51
- {#if expanded}
52
- <div class="ds-lib-activity-steps">
53
- {#each group.steps as step (step.block.index)}
54
- {#if step.block.kind === 'tool'}
55
- <ToolRow
56
- block={step.block}
57
- repeats={step.repeats}
58
- words={step.words}
59
- running={live}
60
- />
61
- {:else}
62
- <ThinkingRow block={step.block} active={false} />
63
- {/if}
64
- {/each}
65
- </div>
66
- {/if}
37
+ {/each}
67
38
  </div>
68
39
 
69
40
  <style>
70
- .ds-lib-activity-toggle {
71
- display: flex;
72
- align-items: center;
73
- gap: 0.375rem;
74
- border: 0;
75
- background: none;
76
- padding: 0;
77
- color: var(--ds-color-muted-foreground);
78
- font: inherit;
79
- font-size: 0.875rem;
80
- line-height: 1.25rem;
81
- cursor: pointer;
82
- transition: color 150ms ease;
83
- }
84
-
85
- .ds-lib-activity-toggle:hover {
86
- color: var(--ds-color-foreground);
87
- }
88
-
89
- .ds-lib-activity-chevron {
90
- display: flex;
91
- flex: none;
92
- transition: transform 150ms ease;
93
- }
94
-
95
- .ds-lib-activity-chevron.is-open {
96
- transform: rotate(90deg);
97
- }
98
-
99
- .ds-lib-activity-dot {
100
- width: 0.375rem;
101
- height: 0.375rem;
102
- flex: none;
103
- border-radius: var(--ds-radius-full);
104
- background: var(--ds-color-status-info);
105
- animation: ds-lib-activity-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
106
- }
107
-
108
41
  .ds-lib-activity-steps {
109
42
  display: flex;
110
43
  flex-direction: column;
111
- gap: 0.375rem;
112
- margin-top: 0.5rem;
113
- margin-inline-start: 0.4375rem;
114
- border-inline-start: 1px solid var(--ds-color-border);
115
- padding-inline-start: 0.75rem;
116
- }
117
-
118
- @keyframes ds-lib-activity-pulse {
119
- 50% {
120
- opacity: 0.4;
121
- }
122
- }
123
-
124
- @media (prefers-reduced-motion: reduce) {
125
- .ds-lib-activity-dot {
126
- animation: none;
127
- }
128
-
129
- .ds-lib-activity-chevron {
130
- transition: none;
131
- }
44
+ gap: 0.25rem;
132
45
  }
133
46
  </style>
@@ -1,8 +1,10 @@
1
- import { type ActivityGroup } from '../../transcript.svelte';
1
+ import type { ActivityGroup } from '../../transcript.svelte';
2
+ import type { ChatCopy } from '../../copy';
2
3
  interface Props {
3
4
  group: ActivityGroup;
4
5
  /** True while this group is the live one. */
5
6
  live: boolean;
7
+ copy?: Partial<ChatCopy>;
6
8
  }
7
9
  declare const ActivityGroup: import("svelte").Component<Props, {}, "">;
8
10
  type ActivityGroup = ReturnType<typeof ActivityGroup>;
@@ -15,6 +15,8 @@
15
15
  <script lang="ts">
16
16
  import CheckIcon from '@lucide/svelte/icons/check';
17
17
  import CopyIcon from '@lucide/svelte/icons/copy';
18
+ import PencilIcon from '@lucide/svelte/icons/pencil';
19
+ import PaperclipIcon from '@lucide/svelte/icons/paperclip';
18
20
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
19
21
  import {
20
22
  outcomeLine,
@@ -34,6 +36,8 @@
34
36
  type Citation
35
37
  } from '../../citations';
36
38
  import { DEFAULT_PERSONA, personaName, resolveCopy, type ChatCopy } from '../../copy';
39
+ import { webSources } from '../../web-sources';
40
+ import { writtenKey, type Written, type WrittenUrl } from '../../written';
37
41
  import type { Verdict } from '../../chat.svelte';
38
42
  import AnswerMark from '../answer-mark/answer-mark.svelte';
39
43
  import AnswerShare from '../answer-share/answer-share.svelte';
@@ -41,7 +45,10 @@
41
45
  import ActivityGroup from '../activity-group/activity-group.svelte';
42
46
  import ArtefactCard from '../artefact-card/artefact-card.svelte';
43
47
  import Markdown from '../markdown/markdown.svelte';
48
+ import type { Fences } from '../markdown/markdown';
44
49
  import SourceList from '../source-list/source-list.svelte';
50
+ import WebList from '../web-list/web-list.svelte';
51
+ import WrittenCard from '../written-card/written-card.svelte';
45
52
 
46
53
  /** Everything one turn renders from: what `Conversation` hands a host's
47
54
  * own `turn` snippet, so it can render this component or its own. */
@@ -76,6 +83,22 @@
76
83
  onopenartefact?: () => void;
77
84
  /** The persona's own words for its tools, and what they read. */
78
85
  describeTool?: DescribeTool;
86
+ /** The names of the files that went with the question, shown as chips. */
87
+ files?: string[];
88
+ /** Replaces this question with `text` and asks it again from here
89
+ * (`Chat.edit`), resolving false when that did not go through. Omit
90
+ * and no edit control renders: not while an answer is being written. */
91
+ onedit?: (text: string) => Promise<boolean>;
92
+ /** The documents the persona wrote answering it, carded under the answer. */
93
+ written?: Written[];
94
+ /** The version the reading column shows now, by `writtenKey`. */
95
+ showingWritten?: string;
96
+ /** Opens a version in the reading column. Omit and the cards only link. */
97
+ onopenwritten?: (written: Written) => void;
98
+ /** Where a version downloads. Omit and no download links render. */
99
+ writtenUrl?: WrittenUrl;
100
+ /** Languages of fenced code the host draws with its own component. */
101
+ fences?: Fences;
79
102
  /** When an answer this page did not start began, epoch ms: it is still
80
103
  * being written, and the clock counts from then. */
81
104
  answering?: number;
@@ -111,6 +134,13 @@
111
134
  artefactOpen = false,
112
135
  onopenartefact,
113
136
  describeTool,
137
+ files = [],
138
+ onedit,
139
+ written = [],
140
+ showingWritten,
141
+ onopenwritten,
142
+ writtenUrl,
143
+ fences,
114
144
  answering,
115
145
  onmark,
116
146
  shared,
@@ -183,6 +213,28 @@
183
213
 
184
214
  const hasAnswer = $derived(texts.length > 0);
185
215
 
216
+ // Pages the answer drew on, once it has settled: a link still being typed
217
+ // is not a source.
218
+ const web = $derived(running ? [] : webSources(blocks, answer));
219
+
220
+ let editing = $state(false);
221
+ let edited = $state('');
222
+ let sending = $state(false);
223
+
224
+ function startEdit() {
225
+ edited = asked;
226
+ editing = true;
227
+ }
228
+
229
+ async function sendEdit() {
230
+ const text = edited.trim();
231
+ if (!text || !onedit || sending) return;
232
+ sending = true;
233
+ const sent = await onedit(text);
234
+ sending = false;
235
+ if (sent) editing = false;
236
+ }
237
+
186
238
  /**
187
239
  * What went wrong, if anything did — in words, whether or not the run gave
188
240
  * any.
@@ -251,12 +303,51 @@
251
303
  VS Code-style panel: this surface is read by people who arrive with
252
304
  those two as their model of what a chat looks like. A session whose
253
305
  stream never carried its prompt has no words to put here. -->
254
- {#if asked}
306
+ {#if asked && editing}
307
+ <form
308
+ class="ds-lib-ask ds-lib-edit"
309
+ onsubmit={(event) => {
310
+ event.preventDefault();
311
+ void sendEdit();
312
+ }}
313
+ >
314
+ <textarea
315
+ class="ds-lib-edit-text"
316
+ rows="3"
317
+ aria-label={words.editQuestion}
318
+ bind:value={edited}
319
+ ></textarea>
320
+ {#if files.length > 0}<p class="ds-lib-edit-note">{words.editFilesNote}</p>{/if}
321
+ <div class="ds-lib-edit-actions">
322
+ <button type="button" class="ds-lib-action" onclick={() => (editing = false)}>
323
+ {words.cancel}
324
+ </button>
325
+ <button type="submit" class="ds-lib-action" disabled={!edited.trim() || sending}>
326
+ {words.editSend}
327
+ </button>
328
+ </div>
329
+ </form>
330
+ {:else if asked}
255
331
  <div class="ds-lib-ask">
256
332
  <div class="ds-lib-bubble">{asked}</div>
257
- {#if clock}
258
- <time class="ds-lib-ask-time" datetime={clock.iso}>{clock.text}</time>
333
+ {#if files.length > 0}
334
+ <ul class="ds-lib-ask-files">
335
+ {#each files as file, i (i)}
336
+ <li class="ds-lib-ask-file"><PaperclipIcon size={12} />{file}</li>
337
+ {/each}
338
+ </ul>
259
339
  {/if}
340
+ <div class="ds-lib-ask-meta">
341
+ {#if onedit && !running}
342
+ <button type="button" class="ds-lib-action" onclick={startEdit}>
343
+ <PencilIcon size={14} />
344
+ <span>{words.editQuestion}</span>
345
+ </button>
346
+ {/if}
347
+ {#if clock}
348
+ <time class="ds-lib-ask-time" datetime={clock.iso}>{clock.text}</time>
349
+ {/if}
350
+ </div>
260
351
  </div>
261
352
  {/if}
262
353
 
@@ -279,7 +370,7 @@
279
370
 
280
371
  {#each segments as seg (seg.index)}
281
372
  {#if seg.kind === 'activity'}
282
- <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
373
+ <ActivityGroup group={seg} live={running && seg.index === lastIndex} copy={words} />
283
374
  {:else if !isArtefact}
284
375
  <Markdown
285
376
  content={seg.index === lastTextIndex && split.citations.length > 0
@@ -287,6 +378,7 @@
287
378
  : seg.text}
288
379
  streaming={running && seg.index === lastIndex}
289
380
  {collectionNames}
381
+ {fences}
290
382
  citationNumbers={numbers}
291
383
  oncite={(n) => {
292
384
  const found = sources.find((c) => c.n === n);
@@ -300,10 +392,24 @@
300
392
  <ArtefactCard {artefact} open={artefactOpen} onopen={onopenartefact} copy={words} />
301
393
  {/if}
302
394
 
395
+ {#each written as doc (writtenKey(doc))}
396
+ <WrittenCard
397
+ written={doc}
398
+ open={showingWritten === writtenKey(doc)}
399
+ onopen={onopenwritten ? () => onopenwritten(doc) : undefined}
400
+ url={writtenUrl}
401
+ copy={words}
402
+ />
403
+ {/each}
404
+
303
405
  {#if !isArtefact && sources.length > 0}
304
406
  <SourceList {sources} {words} {oncite} />
305
407
  {/if}
306
408
 
409
+ {#if !isArtefact && web.length > 0}
410
+ <WebList sources={web} {words} />
411
+ {/if}
412
+
307
413
  <!-- An empty `notHeld` is a host whose persona answers from no sources,
308
414
  where "without a source" would be a claim about nothing. -->
309
415
  {#if unanswered && asked}
@@ -395,6 +501,60 @@
395
501
  white-space: pre-wrap;
396
502
  }
397
503
 
504
+ .ds-lib-ask-meta {
505
+ display: flex;
506
+ align-items: center;
507
+ gap: 0.5rem;
508
+ }
509
+
510
+ .ds-lib-ask-files {
511
+ display: flex;
512
+ flex-wrap: wrap;
513
+ justify-content: flex-end;
514
+ gap: 0.25rem;
515
+ margin: 0;
516
+ padding: 0;
517
+ list-style: none;
518
+ }
519
+
520
+ .ds-lib-ask-file {
521
+ display: flex;
522
+ align-items: center;
523
+ gap: 0.25rem;
524
+ border: 1px solid var(--ds-color-border);
525
+ border-radius: var(--ds-radius-md);
526
+ padding: 0.125rem 0.5rem;
527
+ color: var(--ds-color-muted-foreground);
528
+ font-size: var(--ds-text-2xs);
529
+ }
530
+
531
+ .ds-lib-edit {
532
+ align-items: stretch;
533
+ }
534
+
535
+ .ds-lib-edit-text {
536
+ width: 100%;
537
+ resize: vertical;
538
+ border: 1px solid var(--ds-color-border);
539
+ border-radius: var(--ds-radius-lg);
540
+ background: var(--ds-color-surface-1);
541
+ padding: 0.5rem 0.75rem;
542
+ color: var(--ds-color-foreground);
543
+ font: inherit;
544
+ }
545
+
546
+ .ds-lib-edit-note {
547
+ margin: 0;
548
+ color: var(--ds-color-muted-foreground);
549
+ font-size: var(--ds-text-2xs);
550
+ }
551
+
552
+ .ds-lib-edit-actions {
553
+ display: flex;
554
+ justify-content: flex-end;
555
+ gap: 0.25rem;
556
+ }
557
+
398
558
  .ds-lib-ask-time,
399
559
  .ds-lib-answer-time {
400
560
  color: var(--ds-color-muted-foreground);