@poodle64/librarian 2026.9.6 → 2026.9.8
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 +128 -43
- package/dist/attachments.d.ts +30 -0
- package/dist/attachments.js +80 -0
- package/dist/citations.d.ts +69 -0
- package/dist/citations.js +102 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +38 -12
- 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 +172 -26
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/composer/composer.svelte +209 -53
- package/dist/components/composer/composer.svelte.d.ts +5 -1
- package/dist/components/conversation/conversation.svelte +180 -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 +181 -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/follow-scroll.svelte.d.ts +34 -0
- package/dist/follow-scroll.svelte.js +46 -0
- package/dist/transcript.svelte.d.ts +27 -0
- package/dist/transcript.svelte.js +27 -0
- package/package.json +16 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
A whole investigation, as
|
|
2
|
+
A whole investigation, as ONE line the reader can open.
|
|
3
3
|
|
|
4
|
-
Fifteen tool rows
|
|
5
|
-
answer
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
Not a stack. Fifteen tool rows between the question and the first word of the
|
|
5
|
+
answer is the machine room on show, and a running commentary of "Let me
|
|
6
|
+
check…" is worse: it narrates a mechanism Milton is under instruction never
|
|
7
|
+
to mention. So there is exactly one row in every state — "Working…" while it
|
|
8
|
+
runs, a count of what was done once it settles — and the steps are behind a
|
|
9
|
+
disclosure for the reader who wants them.
|
|
9
10
|
-->
|
|
10
11
|
<script lang="ts">
|
|
11
12
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
@@ -15,49 +16,40 @@
|
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
group: ActivityGroup;
|
|
18
|
-
/** True while this group is the live one
|
|
19
|
+
/** True while this group is the live one. */
|
|
19
20
|
live: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
let { group, live }: Props = $props();
|
|
23
24
|
let expanded = $state(false);
|
|
24
|
-
|
|
25
|
+
|
|
26
|
+
// A live group has nothing to count yet, so counting it reads as "0
|
|
27
|
+
// searches" flickering up to the real number.
|
|
28
|
+
const label = $derived(live ? 'Working…' : summariseActivity(group));
|
|
25
29
|
</script>
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{/if}
|
|
38
|
-
{/each}
|
|
39
|
-
</div>
|
|
40
|
-
{:else}
|
|
41
|
-
<div>
|
|
42
|
-
<button
|
|
43
|
-
type="button"
|
|
44
|
-
class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
|
|
45
|
-
onclick={() => (expanded = !expanded)}
|
|
46
|
-
aria-expanded={expanded}
|
|
47
|
-
>
|
|
48
|
-
<ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
|
|
49
|
-
<span>{summariseActivity(group)}</span>
|
|
50
|
-
</button>
|
|
51
|
-
{#if open}
|
|
52
|
-
<div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
|
|
53
|
-
{#each group.steps as step (step.block.index)}
|
|
54
|
-
{#if step.block.kind === 'tool'}
|
|
55
|
-
<ToolRow block={step.block} repeats={step.repeats} running={false} />
|
|
56
|
-
{:else}
|
|
57
|
-
<ThinkingRow block={step.block} active={false} />
|
|
58
|
-
{/if}
|
|
59
|
-
{/each}
|
|
60
|
-
</div>
|
|
31
|
+
<div>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
|
|
35
|
+
onclick={() => (expanded = !expanded)}
|
|
36
|
+
aria-expanded={expanded}
|
|
37
|
+
>
|
|
38
|
+
<ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
|
|
39
|
+
{#if live}
|
|
40
|
+
<span class="bg-status-info size-1.5 shrink-0 animate-pulse rounded-full"></span>
|
|
61
41
|
{/if}
|
|
62
|
-
|
|
63
|
-
|
|
42
|
+
<span>{label}</span>
|
|
43
|
+
</button>
|
|
44
|
+
{#if expanded}
|
|
45
|
+
<div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
|
|
46
|
+
{#each group.steps as step (step.block.index)}
|
|
47
|
+
{#if step.block.kind === 'tool'}
|
|
48
|
+
<ToolRow block={step.block} repeats={step.repeats} running={live} />
|
|
49
|
+
{:else}
|
|
50
|
+
<ThinkingRow block={step.block} active={false} />
|
|
51
|
+
{/if}
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ActivityGroup } from '../../transcript.svelte';
|
|
2
2
|
interface Props {
|
|
3
3
|
group: ActivityGroup;
|
|
4
|
-
/** True while this group is the live one
|
|
4
|
+
/** True while this group is the live one. */
|
|
5
5
|
live: boolean;
|
|
6
6
|
}
|
|
7
7
|
declare const ActivityGroup: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
One question and everything
|
|
2
|
+
One question and everything Milton did answering it.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Three things carry the shape. The question is a bubble against the edge, so a
|
|
5
|
+
reader scanning back finds their own words without reading any of the answer.
|
|
6
|
+
The answer is plain prose held to a reading measure, because a full-width
|
|
7
|
+
line at 1440 is the single clearest tell that a surface was never read on.
|
|
8
|
+
Everything Milton DID is one quiet line above it, never a stack.
|
|
7
9
|
-->
|
|
8
10
|
<script lang="ts">
|
|
9
|
-
import
|
|
11
|
+
import CheckIcon from '@lucide/svelte/icons/check';
|
|
12
|
+
import CopyIcon from '@lucide/svelte/icons/copy';
|
|
13
|
+
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
14
|
+
import {
|
|
15
|
+
readerQuestion,
|
|
16
|
+
segment,
|
|
17
|
+
type Block,
|
|
18
|
+
type Outcome,
|
|
19
|
+
type TextBlock
|
|
20
|
+
} from '../../transcript.svelte';
|
|
21
|
+
import {
|
|
22
|
+
citationMarkers,
|
|
23
|
+
resolveCitations,
|
|
24
|
+
splitSources,
|
|
25
|
+
type Citation
|
|
26
|
+
} from '../../citations';
|
|
10
27
|
import Working from '../working/working.svelte';
|
|
11
28
|
import ActivityGroup from '../activity-group/activity-group.svelte';
|
|
12
29
|
import Markdown from '../markdown/markdown.svelte';
|
|
@@ -16,16 +33,79 @@
|
|
|
16
33
|
blocks: Block[];
|
|
17
34
|
outcome: Outcome | null;
|
|
18
35
|
running: boolean;
|
|
36
|
+
/** Sources from the library's `citations` frame. Absent falls back to
|
|
37
|
+
* whatever a "## Sources" block in the prose says. */
|
|
38
|
+
citations?: Citation[];
|
|
19
39
|
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
20
40
|
collectionNames?: Set<string>;
|
|
41
|
+
/** Opens the document pane. Omit and chips render but do not open. */
|
|
42
|
+
oncite?: (citation: Citation) => void;
|
|
43
|
+
/** Offered on the LAST answer only — re-asks the same question. */
|
|
44
|
+
onregenerate?: () => void;
|
|
21
45
|
}
|
|
22
46
|
|
|
23
|
-
let {
|
|
47
|
+
let {
|
|
48
|
+
question,
|
|
49
|
+
blocks,
|
|
50
|
+
outcome,
|
|
51
|
+
running,
|
|
52
|
+
citations = [],
|
|
53
|
+
collectionNames = new Set(),
|
|
54
|
+
oncite,
|
|
55
|
+
onregenerate
|
|
56
|
+
}: Props = $props();
|
|
24
57
|
|
|
25
|
-
|
|
26
|
-
|
|
58
|
+
const asked = $derived(readerQuestion(question));
|
|
59
|
+
|
|
60
|
+
// An activity group stays live — and therefore labelled "Working…" — only
|
|
61
|
+
// while it is the last thing in the turn. As soon as prose arrives, it
|
|
62
|
+
// settles into its count.
|
|
27
63
|
const segments = $derived(segment(blocks));
|
|
28
64
|
const lastIndex = $derived(segments.at(-1)?.index ?? -1);
|
|
65
|
+
|
|
66
|
+
// An answer can arrive as SEVERAL text blocks with tool calls between them,
|
|
67
|
+
// so the Sources block is at the end of the LAST one. It is split off that
|
|
68
|
+
// block alone: splitting the joined answer and substituting the result back
|
|
69
|
+
// into one segment would print every earlier block twice.
|
|
70
|
+
//
|
|
71
|
+
// And only once the answer has settled — mid-stream the "## Sources" heading
|
|
72
|
+
// may be all that has arrived, with the list still to come.
|
|
73
|
+
const texts = $derived(segments.filter((s): s is TextBlock => s.kind === 'text'));
|
|
74
|
+
const lastTextIndex = $derived(texts.at(-1)?.index ?? -1);
|
|
75
|
+
const split = $derived(
|
|
76
|
+
running ? { body: '', citations: [] } : splitSources(texts.at(-1)?.text ?? '')
|
|
77
|
+
);
|
|
78
|
+
const sources = $derived(resolveCitations(citations, split.citations));
|
|
79
|
+
|
|
80
|
+
/** The answer as a reader would paste it: every block, Sources stripped. */
|
|
81
|
+
const answer = $derived(
|
|
82
|
+
texts
|
|
83
|
+
.map((s) => (s.index === lastTextIndex && split.citations.length > 0 ? split.body : s.text))
|
|
84
|
+
.join('\n\n')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Only a number the prose actually marks becomes a chip; a source list may
|
|
88
|
+
// legitimately carry an entry the answer never points at inline.
|
|
89
|
+
const numbers = $derived(
|
|
90
|
+
new Set(citationMarkers(answer).filter((n) => sources.some((c) => c.n === n)))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const hasAnswer = $derived(texts.length > 0);
|
|
94
|
+
// A failed turn settles too, and "Ask again" is the one thing a reader wants
|
|
95
|
+
// from it — there is just nothing to copy.
|
|
96
|
+
const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
|
|
97
|
+
|
|
98
|
+
let copied = $state(false);
|
|
99
|
+
async function copy() {
|
|
100
|
+
try {
|
|
101
|
+
await navigator.clipboard.writeText(answer);
|
|
102
|
+
copied = true;
|
|
103
|
+
setTimeout(() => (copied = false), 1600);
|
|
104
|
+
} catch {
|
|
105
|
+
// A denied clipboard permission is not worth an error state on an
|
|
106
|
+
// answer the reader can still select by hand.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
29
109
|
</script>
|
|
30
110
|
|
|
31
111
|
<article class="flex flex-col gap-3">
|
|
@@ -33,9 +113,9 @@
|
|
|
33
113
|
panel: this surface is read by people who arrive with those two as
|
|
34
114
|
their model of what a chat looks like. -->
|
|
35
115
|
<div
|
|
36
|
-
class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base"
|
|
116
|
+
class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base break-words whitespace-pre-wrap"
|
|
37
117
|
>
|
|
38
|
-
{
|
|
118
|
+
{asked}
|
|
39
119
|
</div>
|
|
40
120
|
|
|
41
121
|
{#if running && segments.length === 0}
|
|
@@ -46,26 +126,92 @@
|
|
|
46
126
|
{#if seg.kind === 'activity'}
|
|
47
127
|
<ActivityGroup group={seg} live={running && seg.index === lastIndex} />
|
|
48
128
|
{:else}
|
|
49
|
-
<div class="
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
129
|
+
<div class="max-w-[72ch] min-w-0">
|
|
130
|
+
<Markdown
|
|
131
|
+
content={seg.index === lastTextIndex && split.citations.length > 0
|
|
132
|
+
? split.body
|
|
133
|
+
: seg.text}
|
|
134
|
+
streaming={running && seg.index === lastIndex}
|
|
135
|
+
{collectionNames}
|
|
136
|
+
citationNumbers={numbers}
|
|
137
|
+
oncite={(n) => {
|
|
138
|
+
const found = sources.find((c) => c.n === n);
|
|
139
|
+
if (found) oncite?.(found);
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
58
142
|
</div>
|
|
59
143
|
{/if}
|
|
60
144
|
{/each}
|
|
61
145
|
|
|
62
|
-
{#if
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
146
|
+
{#if sources.length > 0}
|
|
147
|
+
<section class="max-w-[72ch]">
|
|
148
|
+
<h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
149
|
+
Sources
|
|
150
|
+
</h2>
|
|
151
|
+
<ol class="flex flex-col gap-1">
|
|
152
|
+
{#each sources as source (source.n)}
|
|
153
|
+
<li>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onclick={() => oncite?.(source)}
|
|
157
|
+
disabled={!source.document_id || !oncite}
|
|
158
|
+
class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
|
|
159
|
+
>
|
|
160
|
+
<span
|
|
161
|
+
class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
|
|
162
|
+
>{source.n}</span
|
|
163
|
+
>
|
|
164
|
+
<span class="min-w-0">
|
|
165
|
+
<span class="text-foreground">{source.title}</span>
|
|
166
|
+
{#if source.section}<span class="text-muted-foreground">
|
|
167
|
+
· {source.section}</span
|
|
168
|
+
>{/if}
|
|
169
|
+
</span>
|
|
170
|
+
</button>
|
|
171
|
+
</li>
|
|
172
|
+
{/each}
|
|
173
|
+
</ol>
|
|
174
|
+
</section>
|
|
175
|
+
{/if}
|
|
176
|
+
|
|
177
|
+
{#if outcome?.error}
|
|
178
|
+
<p
|
|
179
|
+
class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
|
|
180
|
+
role="alert"
|
|
181
|
+
>
|
|
182
|
+
{outcome.error}
|
|
69
183
|
</p>
|
|
70
184
|
{/if}
|
|
185
|
+
|
|
186
|
+
{#if settled}
|
|
187
|
+
<div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
|
|
188
|
+
{#if hasAnswer}
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
onclick={copy}
|
|
192
|
+
aria-label={copied ? 'Copied' : 'Copy answer'}
|
|
193
|
+
class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
194
|
+
>
|
|
195
|
+
{#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
|
|
196
|
+
<span>{copied ? 'Copied' : 'Copy'}</span>
|
|
197
|
+
</button>
|
|
198
|
+
{/if}
|
|
199
|
+
{#if onregenerate}
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onclick={onregenerate}
|
|
203
|
+
aria-label="Ask again"
|
|
204
|
+
class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
205
|
+
>
|
|
206
|
+
<RefreshCwIcon class="size-3.5" />
|
|
207
|
+
<span>Ask again</span>
|
|
208
|
+
</button>
|
|
209
|
+
{/if}
|
|
210
|
+
{#if outcome && !outcome.error}
|
|
211
|
+
<span class="pl-1 font-mono text-xs tabular-nums opacity-70">
|
|
212
|
+
{((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
|
|
213
|
+
</span>
|
|
214
|
+
{/if}
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
71
217
|
</article>
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { type Block, type Outcome } from '../../transcript.svelte';
|
|
2
|
+
import { type Citation } from '../../citations';
|
|
2
3
|
interface Props {
|
|
3
4
|
question: string;
|
|
4
5
|
blocks: Block[];
|
|
5
6
|
outcome: Outcome | null;
|
|
6
7
|
running: boolean;
|
|
8
|
+
/** Sources from the library's `citations` frame. Absent falls back to
|
|
9
|
+
* whatever a "## Sources" block in the prose says. */
|
|
10
|
+
citations?: Citation[];
|
|
7
11
|
/** Forwarded to Markdown; omit if the caller has no collections to chip. */
|
|
8
12
|
collectionNames?: Set<string>;
|
|
13
|
+
/** Opens the document pane. Omit and chips render but do not open. */
|
|
14
|
+
oncite?: (citation: Citation) => void;
|
|
15
|
+
/** Offered on the LAST answer only — re-asks the same question. */
|
|
16
|
+
onregenerate?: () => void;
|
|
9
17
|
}
|
|
10
18
|
declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
|
|
11
19
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -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,8 +45,10 @@
|
|
|
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
|
|
@@ -62,64 +77,205 @@
|
|
|
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. It has to be the transition OUT of `running`: a
|
|
105
|
+
// disabled element cannot hold focus, so the focus call in `submit()` below
|
|
106
|
+
// survives only for a host that never sets `running` — measured, focus lands
|
|
107
|
+
// on `body` the frame the box disables and comes back here.
|
|
108
|
+
//
|
|
109
|
+
// Plain, not `$state`: read and written in the same effect, which would
|
|
110
|
+
// otherwise re-trigger itself forever.
|
|
111
|
+
let wasRunning = false;
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (wasRunning && !running) textarea?.focus();
|
|
114
|
+
wasRunning = running;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function submit() {
|
|
118
|
+
if (!value.trim() || running) return;
|
|
119
|
+
onsubmit();
|
|
120
|
+
textarea?.focus();
|
|
121
|
+
}
|
|
122
|
+
|
|
65
123
|
function keydown(event: KeyboardEvent) {
|
|
66
124
|
// Enter sends, Shift+Enter breaks the line — the convention in every
|
|
67
125
|
// reference and the one a user will try first.
|
|
68
126
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
69
127
|
event.preventDefault();
|
|
70
|
-
|
|
128
|
+
submit();
|
|
71
129
|
}
|
|
72
130
|
}
|
|
131
|
+
|
|
132
|
+
function take(list: FileList | File[] | null) {
|
|
133
|
+
if (!list) return;
|
|
134
|
+
const check = acceptFiles(files, list);
|
|
135
|
+
files = check.accepted;
|
|
136
|
+
rejected = rejectionMessage(check.rejected);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pick(event: Event) {
|
|
140
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
141
|
+
take(input.files);
|
|
142
|
+
// Same file twice in a row fires no change event unless the value is
|
|
143
|
+
// cleared, so removing a chip and re-adding it would silently do nothing.
|
|
144
|
+
input.value = '';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function remove(index: number) {
|
|
148
|
+
files = files.filter((_, i) => i !== index);
|
|
149
|
+
rejected = '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The keyboard does not shrink the layout viewport on iOS, so a composer at
|
|
153
|
+
// the bottom of a full-height column sits UNDER it. The visual viewport is
|
|
154
|
+
// the one that moves; the overlap between the two is exactly how far the
|
|
155
|
+
// box must rise.
|
|
156
|
+
let lift = $state(0);
|
|
157
|
+
$effect(() => {
|
|
158
|
+
const viewport = globalThis.visualViewport;
|
|
159
|
+
if (!viewport) return;
|
|
160
|
+
const measure = () => {
|
|
161
|
+
lift = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
162
|
+
};
|
|
163
|
+
measure();
|
|
164
|
+
viewport.addEventListener('resize', measure);
|
|
165
|
+
viewport.addEventListener('scroll', measure);
|
|
166
|
+
return () => {
|
|
167
|
+
viewport.removeEventListener('resize', measure);
|
|
168
|
+
viewport.removeEventListener('scroll', measure);
|
|
169
|
+
};
|
|
170
|
+
});
|
|
73
171
|
</script>
|
|
74
172
|
|
|
75
173
|
<div
|
|
76
|
-
|
|
174
|
+
style="transform: translateY(-{lift}px); padding-bottom: {lift
|
|
175
|
+
? '0px'
|
|
176
|
+
: 'env(safe-area-inset-bottom, 0px)'}"
|
|
177
|
+
class="transition-transform duration-150"
|
|
77
178
|
>
|
|
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'}"
|
|
179
|
+
<div
|
|
180
|
+
class="border-border bg-surface-1 focus-within:border-primary/60 rounded-xl border transition-colors"
|
|
181
|
+
>
|
|
182
|
+
{#if files.length > 0}
|
|
183
|
+
<ul class="flex flex-wrap gap-1.5 px-3 pt-3">
|
|
184
|
+
{#each files as file, index (file.name + file.size)}
|
|
185
|
+
<li
|
|
186
|
+
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
187
|
>
|
|
98
|
-
{
|
|
99
|
-
|
|
188
|
+
<span class="text-foreground truncate">{file.name}</span>
|
|
189
|
+
<span class="text-muted-foreground shrink-0 tabular-nums">{formatSize(file.size)}</span
|
|
190
|
+
>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onclick={() => remove(index)}
|
|
194
|
+
aria-label="Remove {file.name}"
|
|
195
|
+
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"
|
|
196
|
+
>
|
|
197
|
+
<XIcon class="size-3" />
|
|
198
|
+
</button>
|
|
199
|
+
</li>
|
|
100
200
|
{/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>
|
|
201
|
+
</ul>
|
|
123
202
|
{/if}
|
|
203
|
+
|
|
204
|
+
<textarea
|
|
205
|
+
bind:this={textarea}
|
|
206
|
+
bind:value
|
|
207
|
+
oninput={grow}
|
|
208
|
+
onkeydown={keydown}
|
|
209
|
+
rows="1"
|
|
210
|
+
disabled={running}
|
|
211
|
+
placeholder={running ? 'Milton is answering…' : 'Ask Milton…'}
|
|
212
|
+
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"
|
|
213
|
+
></textarea>
|
|
214
|
+
|
|
215
|
+
<div class="flex items-center gap-2 px-3 pb-2.5">
|
|
216
|
+
{#if attachments}
|
|
217
|
+
<input
|
|
218
|
+
bind:this={picker}
|
|
219
|
+
type="file"
|
|
220
|
+
multiple
|
|
221
|
+
accept={ACCEPT_ATTRIBUTE}
|
|
222
|
+
onchange={pick}
|
|
223
|
+
class="hidden"
|
|
224
|
+
tabindex="-1"
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
/>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onclick={() => picker?.click()}
|
|
230
|
+
disabled={running || files.length >= MAX_FILES}
|
|
231
|
+
aria-label="Attach a file"
|
|
232
|
+
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"
|
|
233
|
+
>
|
|
234
|
+
<PaperclipIcon class="size-4" />
|
|
235
|
+
</button>
|
|
236
|
+
{/if}
|
|
237
|
+
|
|
238
|
+
{#if hasChoice}
|
|
239
|
+
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
|
240
|
+
{#each choices as choice (choice.id)}
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onclick={() => onscope(choice.id)}
|
|
244
|
+
class="max-w-full truncate rounded-full px-2 py-0.5 text-xs transition-colors {scope ===
|
|
245
|
+
choice.id
|
|
246
|
+
? 'bg-primary/15 text-foreground border-primary/40 border'
|
|
247
|
+
: 'text-muted-foreground hover:text-foreground border border-transparent'}"
|
|
248
|
+
>
|
|
249
|
+
{choice.label}
|
|
250
|
+
</button>
|
|
251
|
+
{/each}
|
|
252
|
+
</div>
|
|
253
|
+
{/if}
|
|
254
|
+
<span class="flex-1"></span>
|
|
255
|
+
{#if running}
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onclick={onstop}
|
|
259
|
+
aria-label="Stop"
|
|
260
|
+
class="bg-foreground text-background hover:bg-foreground/90 flex size-8 items-center justify-center rounded-full transition-colors"
|
|
261
|
+
>
|
|
262
|
+
<SquareIcon class="size-3.5 fill-current" />
|
|
263
|
+
</button>
|
|
264
|
+
{:else}
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onclick={submit}
|
|
268
|
+
disabled={!value.trim()}
|
|
269
|
+
aria-label="Send"
|
|
270
|
+
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"
|
|
271
|
+
>
|
|
272
|
+
<ArrowUpIcon class="size-4" />
|
|
273
|
+
</button>
|
|
274
|
+
{/if}
|
|
275
|
+
</div>
|
|
124
276
|
</div>
|
|
277
|
+
|
|
278
|
+
{#if rejected}
|
|
279
|
+
<p class="text-status-error px-1 pt-1.5 text-xs" role="status" aria-live="polite">{rejected}</p>
|
|
280
|
+
{/if}
|
|
125
281
|
</div>
|