@poodle64/librarian 2026.9.5 → 2026.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -44
- package/dist/attachments.d.ts +30 -0
- package/dist/attachments.js +80 -0
- package/dist/citations.d.ts +63 -0
- package/dist/citations.js +89 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +39 -13
- package/dist/components/activity-group/activity-group.svelte +36 -44
- package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +166 -26
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/composer/composer.svelte +212 -59
- package/dist/components/composer/composer.svelte.d.ts +5 -1
- package/dist/components/conversation/conversation.svelte +179 -0
- package/dist/components/conversation/conversation.svelte.d.ts +26 -0
- package/dist/components/conversation/index.d.ts +2 -0
- package/dist/components/conversation/index.js +2 -0
- package/dist/components/document-pane/document-pane.svelte +177 -0
- package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
- package/dist/components/document-pane/index.d.ts +2 -0
- package/dist/components/document-pane/index.js +2 -0
- package/dist/components/markdown/index.d.ts +1 -1
- package/dist/components/markdown/index.js +1 -1
- package/dist/components/markdown/markdown.d.ts +13 -0
- package/dist/components/markdown/markdown.js +49 -1
- package/dist/components/markdown/markdown.svelte +85 -3
- package/dist/components/markdown/markdown.svelte.d.ts +6 -0
- package/dist/components/tool-row/tool-row.svelte +5 -11
- package/dist/components/working/working.svelte +1 -9
- package/dist/follow-scroll.svelte.d.ts +34 -0
- package/dist/follow-scroll.svelte.js +46 -0
- package/dist/transcript.svelte.d.ts +36 -4
- package/dist/transcript.svelte.js +50 -26
- package/package.json +16 -2
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The composer: one rounded box holding the input AND a footer row.
|
|
2
|
+
The composer: one rounded box holding attachments, the input AND a footer row.
|
|
3
3
|
|
|
4
|
-
Two things carry it: the chips sit BELOW the input inside the same box
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the
|
|
4
|
+
Two things carry it: the chips sit BELOW the input inside the same box rather
|
|
5
|
+
than beside it, and the trailing control is a circular filled accent button.
|
|
6
|
+
The empty→typing transition lives almost entirely in that control — the box
|
|
7
|
+
chrome grows with the text and nothing else moves.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
While Milton is answering the box is disabled and the send button becomes a
|
|
10
|
+
stop button. Queuing the next message is not built, and a box that takes
|
|
11
|
+
typing it will then discard is worse than one that plainly waits.
|
|
12
12
|
-->
|
|
13
13
|
<script lang="ts">
|
|
14
14
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
15
|
+
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
|
15
16
|
import SquareIcon from '@lucide/svelte/icons/square';
|
|
17
|
+
import XIcon from '@lucide/svelte/icons/x';
|
|
18
|
+
import {
|
|
19
|
+
ACCEPT_ATTRIBUTE,
|
|
20
|
+
acceptFiles,
|
|
21
|
+
formatSize,
|
|
22
|
+
MAX_FILES,
|
|
23
|
+
rejectionMessage
|
|
24
|
+
} from '../../attachments';
|
|
16
25
|
|
|
17
26
|
export type Scope = 'document' | 'collection' | 'library';
|
|
18
27
|
|
|
@@ -20,9 +29,13 @@
|
|
|
20
29
|
value: string;
|
|
21
30
|
running: boolean;
|
|
22
31
|
scope: Scope;
|
|
32
|
+
/** Files to send with the next question. Cleared by the caller on send. */
|
|
33
|
+
files?: File[];
|
|
23
34
|
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
24
35
|
documentName?: string;
|
|
25
36
|
collectionName?: string;
|
|
37
|
+
/** Hides the paperclip for a host whose ask route takes no files. */
|
|
38
|
+
attachments?: boolean;
|
|
26
39
|
onscope: (scope: Scope) => void;
|
|
27
40
|
onsubmit: () => void;
|
|
28
41
|
onstop: () => void;
|
|
@@ -32,23 +45,25 @@
|
|
|
32
45
|
value = $bindable(),
|
|
33
46
|
running,
|
|
34
47
|
scope,
|
|
48
|
+
files = $bindable([]),
|
|
35
49
|
documentName,
|
|
36
50
|
collectionName,
|
|
51
|
+
attachments = true,
|
|
37
52
|
onscope,
|
|
38
53
|
onsubmit,
|
|
39
54
|
onstop
|
|
40
55
|
}: Props = $props();
|
|
41
56
|
|
|
42
|
-
// What
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
57
|
+
// What Milton is being asked about — this document, this collection, or
|
|
58
|
+
// the whole library. Callers that have no narrower scope pass neither
|
|
59
|
+
// name, leaving "The whole library" the sole entry — and a sole entry is
|
|
60
|
+
// not a choice, so `hasChoice` below hides it rather than rendering a
|
|
61
|
+
// chip with nothing to switch to.
|
|
47
62
|
const choices = $derived(
|
|
48
63
|
[
|
|
49
64
|
documentName ? { id: 'document' as const, label: documentName } : null,
|
|
50
65
|
collectionName ? { id: 'collection' as const, label: collectionName } : null,
|
|
51
|
-
{ id: 'library' as const, label: '
|
|
66
|
+
{ id: 'library' as const, label: 'The whole library' }
|
|
52
67
|
].filter((c) => c !== null)
|
|
53
68
|
);
|
|
54
69
|
|
|
@@ -62,64 +77,202 @@
|
|
|
62
77
|
// ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
|
|
63
78
|
// cannot finish reading is not a control, it is decoration.
|
|
64
79
|
|
|
80
|
+
let textarea = $state<HTMLTextAreaElement | null>(null);
|
|
81
|
+
let picker = $state<HTMLInputElement | null>(null);
|
|
82
|
+
let rejected = $state('');
|
|
83
|
+
|
|
84
|
+
/** Six lines, then it scrolls. A `rows` attribute cannot express that —
|
|
85
|
+
* it sets the floor, not the ceiling — so the height is measured from the
|
|
86
|
+
* content and capped. `height: auto` first, or `scrollHeight` reports the
|
|
87
|
+
* height the box already has and the box can only ever grow. */
|
|
88
|
+
function grow() {
|
|
89
|
+
const el = textarea;
|
|
90
|
+
if (!el) return;
|
|
91
|
+
el.style.height = 'auto';
|
|
92
|
+
const line = parseFloat(getComputedStyle(el).lineHeight) || 24;
|
|
93
|
+
el.style.height = `${Math.min(el.scrollHeight, line * 6 + 20)}px`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
$effect(() => {
|
|
97
|
+
// Read `value` so a programmatic change (an example question tapped in
|
|
98
|
+
// the empty state) resizes the box too, not only typing.
|
|
99
|
+
void value;
|
|
100
|
+
grow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Focus comes back once Milton is done, so a reader can keep going without
|
|
104
|
+
// reaching for the mouse. Focusing at send is pointless — the box is
|
|
105
|
+
// disabled a frame later — so the transition out of `running` is the hook.
|
|
106
|
+
// Plain, not `$state`: read and written in the same effect, which would
|
|
107
|
+
// otherwise re-trigger itself forever.
|
|
108
|
+
let wasRunning = false;
|
|
109
|
+
$effect(() => {
|
|
110
|
+
if (wasRunning && !running) textarea?.focus();
|
|
111
|
+
wasRunning = running;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function submit() {
|
|
115
|
+
if (!value.trim() || running) return;
|
|
116
|
+
onsubmit();
|
|
117
|
+
textarea?.focus();
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
function keydown(event: KeyboardEvent) {
|
|
66
121
|
// Enter sends, Shift+Enter breaks the line — the convention in every
|
|
67
122
|
// reference and the one a user will try first.
|
|
68
123
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
69
124
|
event.preventDefault();
|
|
70
|
-
|
|
125
|
+
submit();
|
|
71
126
|
}
|
|
72
127
|
}
|
|
128
|
+
|
|
129
|
+
function take(list: FileList | File[] | null) {
|
|
130
|
+
if (!list) return;
|
|
131
|
+
const check = acceptFiles(files, list);
|
|
132
|
+
files = check.accepted;
|
|
133
|
+
rejected = rejectionMessage(check.rejected);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pick(event: Event) {
|
|
137
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
138
|
+
take(input.files);
|
|
139
|
+
// Same file twice in a row fires no change event unless the value is
|
|
140
|
+
// cleared, so removing a chip and re-adding it would silently do nothing.
|
|
141
|
+
input.value = '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function remove(index: number) {
|
|
145
|
+
files = files.filter((_, i) => i !== index);
|
|
146
|
+
rejected = '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The keyboard does not shrink the layout viewport on iOS, so a composer at
|
|
150
|
+
// the bottom of a full-height column sits UNDER it. The visual viewport is
|
|
151
|
+
// the one that moves; the overlap between the two is exactly how far the
|
|
152
|
+
// box must rise.
|
|
153
|
+
let lift = $state(0);
|
|
154
|
+
$effect(() => {
|
|
155
|
+
const viewport = globalThis.visualViewport;
|
|
156
|
+
if (!viewport) return;
|
|
157
|
+
const measure = () => {
|
|
158
|
+
lift = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
159
|
+
};
|
|
160
|
+
measure();
|
|
161
|
+
viewport.addEventListener('resize', measure);
|
|
162
|
+
viewport.addEventListener('scroll', measure);
|
|
163
|
+
return () => {
|
|
164
|
+
viewport.removeEventListener('resize', measure);
|
|
165
|
+
viewport.removeEventListener('scroll', measure);
|
|
166
|
+
};
|
|
167
|
+
});
|
|
73
168
|
</script>
|
|
74
169
|
|
|
75
170
|
<div
|
|
76
|
-
|
|
171
|
+
style="transform: translateY(-{lift}px); padding-bottom: {lift
|
|
172
|
+
? '0px'
|
|
173
|
+
: 'env(safe-area-inset-bottom, 0px)'}"
|
|
174
|
+
class="transition-transform duration-150"
|
|
77
175
|
>
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<div class="flex items-center gap-2 px-3 pb-2.5">
|
|
87
|
-
{#if hasChoice}
|
|
88
|
-
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
|
89
|
-
{#each choices as choice (choice.id)}
|
|
90
|
-
<button
|
|
91
|
-
type="button"
|
|
92
|
-
onclick={() => onscope(choice.id)}
|
|
93
|
-
class="max-w-full truncate rounded-full px-2 py-0.5 text-xs transition-colors {scope ===
|
|
94
|
-
choice.id
|
|
95
|
-
? 'bg-primary/15 text-foreground border-primary/40 border'
|
|
96
|
-
: 'text-muted-foreground hover:text-foreground border border-transparent'}"
|
|
176
|
+
<div
|
|
177
|
+
class="border-border bg-surface-1 focus-within:border-primary/60 rounded-xl border transition-colors"
|
|
178
|
+
>
|
|
179
|
+
{#if files.length > 0}
|
|
180
|
+
<ul class="flex flex-wrap gap-1.5 px-3 pt-3">
|
|
181
|
+
{#each files as file, index (file.name + file.size)}
|
|
182
|
+
<li
|
|
183
|
+
class="border-border bg-surface-2 flex max-w-full items-center gap-1.5 rounded-lg border py-1 pr-1 pl-2 text-xs"
|
|
97
184
|
>
|
|
98
|
-
{
|
|
99
|
-
|
|
185
|
+
<span class="text-foreground truncate">{file.name}</span>
|
|
186
|
+
<span class="text-muted-foreground shrink-0 tabular-nums">{formatSize(file.size)}</span
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onclick={() => remove(index)}
|
|
191
|
+
aria-label="Remove {file.name}"
|
|
192
|
+
class="text-muted-foreground hover:text-foreground hover:bg-surface-3 focus-visible:ring-ring flex size-5 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
193
|
+
>
|
|
194
|
+
<XIcon class="size-3" />
|
|
195
|
+
</button>
|
|
196
|
+
</li>
|
|
100
197
|
{/each}
|
|
101
|
-
</
|
|
102
|
-
{/if}
|
|
103
|
-
<span class="flex-1"></span>
|
|
104
|
-
{#if running}
|
|
105
|
-
<button
|
|
106
|
-
type="button"
|
|
107
|
-
onclick={onstop}
|
|
108
|
-
aria-label="Stop"
|
|
109
|
-
class="bg-foreground text-background hover:bg-foreground/90 flex size-8 items-center justify-center rounded-full transition-colors"
|
|
110
|
-
>
|
|
111
|
-
<SquareIcon class="size-3.5 fill-current" />
|
|
112
|
-
</button>
|
|
113
|
-
{:else}
|
|
114
|
-
<button
|
|
115
|
-
type="button"
|
|
116
|
-
onclick={onsubmit}
|
|
117
|
-
disabled={!value.trim()}
|
|
118
|
-
aria-label="Send"
|
|
119
|
-
class="bg-primary text-primary-foreground hover:bg-primary/90 disabled:hover:bg-primary flex size-8 items-center justify-center rounded-full transition-all disabled:opacity-30"
|
|
120
|
-
>
|
|
121
|
-
<ArrowUpIcon class="size-4" />
|
|
122
|
-
</button>
|
|
198
|
+
</ul>
|
|
123
199
|
{/if}
|
|
200
|
+
|
|
201
|
+
<textarea
|
|
202
|
+
bind:this={textarea}
|
|
203
|
+
bind:value
|
|
204
|
+
oninput={grow}
|
|
205
|
+
onkeydown={keydown}
|
|
206
|
+
rows="1"
|
|
207
|
+
disabled={running}
|
|
208
|
+
placeholder={running ? 'Milton is answering…' : 'Ask Milton…'}
|
|
209
|
+
class="text-foreground placeholder:text-muted-foreground w-full resize-none bg-transparent px-4 pt-3 pb-2 text-base outline-none disabled:opacity-60"
|
|
210
|
+
></textarea>
|
|
211
|
+
|
|
212
|
+
<div class="flex items-center gap-2 px-3 pb-2.5">
|
|
213
|
+
{#if attachments}
|
|
214
|
+
<input
|
|
215
|
+
bind:this={picker}
|
|
216
|
+
type="file"
|
|
217
|
+
multiple
|
|
218
|
+
accept={ACCEPT_ATTRIBUTE}
|
|
219
|
+
onchange={pick}
|
|
220
|
+
class="hidden"
|
|
221
|
+
tabindex="-1"
|
|
222
|
+
aria-hidden="true"
|
|
223
|
+
/>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onclick={() => picker?.click()}
|
|
227
|
+
disabled={running || files.length >= MAX_FILES}
|
|
228
|
+
aria-label="Attach a file"
|
|
229
|
+
class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30"
|
|
230
|
+
>
|
|
231
|
+
<PaperclipIcon class="size-4" />
|
|
232
|
+
</button>
|
|
233
|
+
{/if}
|
|
234
|
+
|
|
235
|
+
{#if hasChoice}
|
|
236
|
+
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
|
237
|
+
{#each choices as choice (choice.id)}
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onclick={() => onscope(choice.id)}
|
|
241
|
+
class="max-w-full truncate rounded-full px-2 py-0.5 text-xs transition-colors {scope ===
|
|
242
|
+
choice.id
|
|
243
|
+
? 'bg-primary/15 text-foreground border-primary/40 border'
|
|
244
|
+
: 'text-muted-foreground hover:text-foreground border border-transparent'}"
|
|
245
|
+
>
|
|
246
|
+
{choice.label}
|
|
247
|
+
</button>
|
|
248
|
+
{/each}
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
251
|
+
<span class="flex-1"></span>
|
|
252
|
+
{#if running}
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onclick={onstop}
|
|
256
|
+
aria-label="Stop"
|
|
257
|
+
class="bg-foreground text-background hover:bg-foreground/90 flex size-8 items-center justify-center rounded-full transition-colors"
|
|
258
|
+
>
|
|
259
|
+
<SquareIcon class="size-3.5 fill-current" />
|
|
260
|
+
</button>
|
|
261
|
+
{:else}
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onclick={submit}
|
|
265
|
+
disabled={!value.trim()}
|
|
266
|
+
aria-label="Send"
|
|
267
|
+
class="bg-primary text-primary-foreground hover:bg-primary/90 disabled:hover:bg-primary flex size-8 items-center justify-center rounded-full transition-all disabled:opacity-30"
|
|
268
|
+
>
|
|
269
|
+
<ArrowUpIcon class="size-4" />
|
|
270
|
+
</button>
|
|
271
|
+
{/if}
|
|
272
|
+
</div>
|
|
124
273
|
</div>
|
|
274
|
+
|
|
275
|
+
{#if rejected}
|
|
276
|
+
<p class="text-status-error px-1 pt-1.5 text-xs" role="status" aria-live="polite">{rejected}</p>
|
|
277
|
+
{/if}
|
|
125
278
|
</div>
|
|
@@ -3,13 +3,17 @@ interface Props {
|
|
|
3
3
|
value: string;
|
|
4
4
|
running: boolean;
|
|
5
5
|
scope: Scope;
|
|
6
|
+
/** Files to send with the next question. Cleared by the caller on send. */
|
|
7
|
+
files?: File[];
|
|
6
8
|
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
7
9
|
documentName?: string;
|
|
8
10
|
collectionName?: string;
|
|
11
|
+
/** Hides the paperclip for a host whose ask route takes no files. */
|
|
12
|
+
attachments?: boolean;
|
|
9
13
|
onscope: (scope: Scope) => void;
|
|
10
14
|
onsubmit: () => void;
|
|
11
15
|
onstop: () => void;
|
|
12
16
|
}
|
|
13
|
-
declare const Composer: import("svelte").Component<Props, {}, "value">;
|
|
17
|
+
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
14
18
|
type Composer = ReturnType<typeof Composer>;
|
|
15
19
|
export default Composer;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The whole reading surface: every turn, the scroll that follows the stream,
|
|
3
|
+
and the source pane beside it.
|
|
4
|
+
|
|
5
|
+
This owns the SCROLL CONTAINER, which is the reason it exists rather than the
|
|
6
|
+
host composing `AgentTranscript` in a div of its own. Follow-scroll, the
|
|
7
|
+
jump-to-latest pill and a document pane that narrows the transcript instead
|
|
8
|
+
of covering it are all one question — where the overflow lives — and three
|
|
9
|
+
hosts answering it separately is three chances to answer it differently.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import type { Snippet } from 'svelte';
|
|
13
|
+
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
|
14
|
+
import type { Turn } from '../../transcript.svelte';
|
|
15
|
+
import type { Citation, LoadDocument } from '../../citations';
|
|
16
|
+
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
17
|
+
import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
|
|
18
|
+
import DocumentPane from '../document-pane/document-pane.svelte';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
turns: Turn[];
|
|
22
|
+
running: boolean;
|
|
23
|
+
/** `Transcript.version` — bumped per event. Text grows in place, so a
|
|
24
|
+
* count of turns is not enough to keep the scroll following. */
|
|
25
|
+
version?: number;
|
|
26
|
+
welcome?: string;
|
|
27
|
+
/** Up to three, shown in the empty state as tappable pills. */
|
|
28
|
+
examples?: string[];
|
|
29
|
+
onexample?: (question: string) => void;
|
|
30
|
+
/** Re-asks the last question. Offered on the last answer only. */
|
|
31
|
+
onregenerate?: () => void;
|
|
32
|
+
/** Enables the source pane. Without it, chips render but do not open. */
|
|
33
|
+
loadDocument?: LoadDocument;
|
|
34
|
+
collectionNames?: Set<string>;
|
|
35
|
+
/** The composer, rendered INSIDE the transcript column so the source
|
|
36
|
+
* pane narrows it too — a composer the host places outside slides
|
|
37
|
+
* under the pane the moment one opens. */
|
|
38
|
+
composer?: Snippet;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let {
|
|
42
|
+
turns,
|
|
43
|
+
running,
|
|
44
|
+
version = 0,
|
|
45
|
+
welcome = 'Ask Milton a question about the library.',
|
|
46
|
+
examples = [],
|
|
47
|
+
onexample,
|
|
48
|
+
onregenerate,
|
|
49
|
+
loadDocument,
|
|
50
|
+
collectionNames = new Set(),
|
|
51
|
+
composer
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
let viewport = $state<HTMLElement | null>(null);
|
|
55
|
+
let open = $state<Citation | null>(null);
|
|
56
|
+
const follow = new FollowScroll();
|
|
57
|
+
|
|
58
|
+
function metrics(el: HTMLElement) {
|
|
59
|
+
return {
|
|
60
|
+
scrollTop: el.scrollTop,
|
|
61
|
+
clientHeight: el.clientHeight,
|
|
62
|
+
scrollHeight: el.scrollHeight
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A new question re-pins: the reader asked for the thing about to arrive,
|
|
67
|
+
// even if they were reading something further up when they asked.
|
|
68
|
+
//
|
|
69
|
+
// Deliberately NOT `$state`: an effect that both reads and writes one piece
|
|
70
|
+
// of reactive state re-triggers itself, which Svelte stops with
|
|
71
|
+
// `effect_update_depth_exceeded` — measured, on this component. Nothing
|
|
72
|
+
// renders this, so a plain variable is both correct and enough.
|
|
73
|
+
let seen = 0;
|
|
74
|
+
$effect(() => {
|
|
75
|
+
if (turns.length > seen) follow.pin();
|
|
76
|
+
seen = turns.length;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
$effect(() => {
|
|
80
|
+
// Both dependencies are deliberate: `version` for text growing inside an
|
|
81
|
+
// existing block, `turns.length` for a whole new turn.
|
|
82
|
+
void version;
|
|
83
|
+
void turns.length;
|
|
84
|
+
if (!viewport || !follow.following) return;
|
|
85
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function scrolled() {
|
|
89
|
+
if (viewport) follow.measure(metrics(viewport));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function jump() {
|
|
93
|
+
if (!viewport) return;
|
|
94
|
+
follow.pin();
|
|
95
|
+
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cite(citation: Citation) {
|
|
99
|
+
// A citation derived from the prose has no id to read, so its chip is
|
|
100
|
+
// legible but inert rather than opening an empty pane.
|
|
101
|
+
if (loadDocument && citation.document_id) open = citation;
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<div class="flex min-h-0 flex-1">
|
|
106
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
107
|
+
<!-- The pill is positioned against the SCROLL region, not the column: the
|
|
108
|
+
composer is in the column too, and a pill over the input is a pill in
|
|
109
|
+
the way. -->
|
|
110
|
+
<div class="relative flex min-h-0 flex-1 flex-col">
|
|
111
|
+
<div
|
|
112
|
+
bind:this={viewport}
|
|
113
|
+
onscroll={scrolled}
|
|
114
|
+
role="log"
|
|
115
|
+
aria-live="polite"
|
|
116
|
+
aria-label="Conversation with Milton"
|
|
117
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
118
|
+
>
|
|
119
|
+
<div class="mx-auto flex w-full max-w-[46rem] flex-col gap-8 px-4 py-6">
|
|
120
|
+
{#if turns.length === 0}
|
|
121
|
+
<div class="flex flex-col items-start gap-4 py-8">
|
|
122
|
+
<p class="text-foreground max-w-[46ch] text-lg leading-snug">{welcome}</p>
|
|
123
|
+
{#if examples.length > 0}
|
|
124
|
+
<ul class="flex flex-col items-start gap-2">
|
|
125
|
+
{#each examples.slice(0, 3) as example (example)}
|
|
126
|
+
<li class="max-w-full">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onclick={() => onexample?.(example)}
|
|
130
|
+
class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
131
|
+
>
|
|
132
|
+
{example}
|
|
133
|
+
</button>
|
|
134
|
+
</li>
|
|
135
|
+
{/each}
|
|
136
|
+
</ul>
|
|
137
|
+
{/if}
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
{#each turns as turn, index (turn.id)}
|
|
142
|
+
<AgentTranscript
|
|
143
|
+
question={turn.question}
|
|
144
|
+
blocks={turn.blocks}
|
|
145
|
+
outcome={turn.outcome}
|
|
146
|
+
running={running && index === turns.length - 1}
|
|
147
|
+
citations={turn.citations ?? []}
|
|
148
|
+
{collectionNames}
|
|
149
|
+
oncite={cite}
|
|
150
|
+
onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
|
|
151
|
+
/>
|
|
152
|
+
{/each}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{#if !follow.following && turns.length > 0}
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onclick={jump}
|
|
160
|
+
class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
161
|
+
>
|
|
162
|
+
<ArrowDownIcon class="size-3.5" />
|
|
163
|
+
Jump to latest
|
|
164
|
+
</button>
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{#if composer}
|
|
169
|
+
<div class="shrink-0 px-4 pb-3">
|
|
170
|
+
<div class="mx-auto w-full max-w-[46rem]">{@render composer()}</div>
|
|
171
|
+
</div>
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{#if open && loadDocument}
|
|
177
|
+
<DocumentPane citation={open} {loadDocument} onclose={() => (open = null)} />
|
|
178
|
+
{/if}
|
|
179
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Turn } from '../../transcript.svelte';
|
|
3
|
+
import type { LoadDocument } from '../../citations';
|
|
4
|
+
interface Props {
|
|
5
|
+
turns: Turn[];
|
|
6
|
+
running: boolean;
|
|
7
|
+
/** `Transcript.version` — bumped per event. Text grows in place, so a
|
|
8
|
+
* count of turns is not enough to keep the scroll following. */
|
|
9
|
+
version?: number;
|
|
10
|
+
welcome?: string;
|
|
11
|
+
/** Up to three, shown in the empty state as tappable pills. */
|
|
12
|
+
examples?: string[];
|
|
13
|
+
onexample?: (question: string) => void;
|
|
14
|
+
/** Re-asks the last question. Offered on the last answer only. */
|
|
15
|
+
onregenerate?: () => void;
|
|
16
|
+
/** Enables the source pane. Without it, chips render but do not open. */
|
|
17
|
+
loadDocument?: LoadDocument;
|
|
18
|
+
collectionNames?: Set<string>;
|
|
19
|
+
/** The composer, rendered INSIDE the transcript column so the source
|
|
20
|
+
* pane narrows it too — a composer the host places outside slides
|
|
21
|
+
* under the pane the moment one opens. */
|
|
22
|
+
composer?: Snippet;
|
|
23
|
+
}
|
|
24
|
+
declare const Conversation: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type Conversation = ReturnType<typeof Conversation>;
|
|
26
|
+
export default Conversation;
|