@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.
Files changed (32) hide show
  1. package/README.md +128 -43
  2. package/dist/attachments.d.ts +30 -0
  3. package/dist/attachments.js +80 -0
  4. package/dist/citations.d.ts +69 -0
  5. package/dist/citations.js +102 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.js +38 -12
  8. package/dist/components/activity-group/activity-group.svelte +36 -44
  9. package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
  10. package/dist/components/agent-transcript/agent-transcript.svelte +172 -26
  11. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
  12. package/dist/components/composer/composer.svelte +209 -53
  13. package/dist/components/composer/composer.svelte.d.ts +5 -1
  14. package/dist/components/conversation/conversation.svelte +180 -0
  15. package/dist/components/conversation/conversation.svelte.d.ts +26 -0
  16. package/dist/components/conversation/index.d.ts +2 -0
  17. package/dist/components/conversation/index.js +2 -0
  18. package/dist/components/document-pane/document-pane.svelte +181 -0
  19. package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
  20. package/dist/components/document-pane/index.d.ts +2 -0
  21. package/dist/components/document-pane/index.js +2 -0
  22. package/dist/components/markdown/index.d.ts +1 -1
  23. package/dist/components/markdown/index.js +1 -1
  24. package/dist/components/markdown/markdown.d.ts +13 -0
  25. package/dist/components/markdown/markdown.js +49 -1
  26. package/dist/components/markdown/markdown.svelte +85 -3
  27. package/dist/components/markdown/markdown.svelte.d.ts +6 -0
  28. package/dist/follow-scroll.svelte.d.ts +34 -0
  29. package/dist/follow-scroll.svelte.js +46 -0
  30. package/dist/transcript.svelte.d.ts +27 -0
  31. package/dist/transcript.svelte.js +27 -0
  32. package/package.json +16 -2
@@ -1,11 +1,12 @@
1
1
  <!--
2
- A whole investigation, as one line the reader can open.
2
+ A whole investigation, as ONE line the reader can open.
3
3
 
4
- Fifteen tool rows stood between the question and the first word of the
5
- answer, so the answer had to be scrolled to. While the agent is working the
6
- steps are worth watching, so they show live. The moment prose starts
7
- arriving they fold to a single summary — the same move Claude makes with
8
- "Researched for 49s".
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 — it stays open. */
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
- const open = $derived(live || expanded);
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
- {#if live}
28
- <div class="flex flex-col gap-1.5">
29
- {#each group.steps as step (step.block.index)}
30
- {#if step.block.kind === 'tool'}
31
- <ToolRow block={step.block} repeats={step.repeats} running={true} />
32
- {:else}
33
- <ThinkingRow
34
- block={step.block}
35
- active={step.block.index === group.steps.at(-1)?.block.index}
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
- </div>
63
- {/if}
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 — it stays open. */
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 the agent did answering it.
2
+ One question and everything Milton did answering it.
3
3
 
4
- User turn as a rounded bubble, assistant prose led by a status dot, tool
5
- calls as rows in the flow, thinking as stacked phase labels that dim once
6
- superseded.
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 { segment, type Block, type Outcome } from '../../transcript.svelte';
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 { question, blocks, outcome, running, collectionNames = new Set() }: Props = $props();
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
- // An activity group stays live — and therefore open — only while it is the
26
- // last thing in the turn. As soon as prose arrives after it, it folds.
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
- {question}
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="flex gap-2.5">
50
- <span class="bg-muted-foreground/40 mt-2.5 size-1.5 shrink-0 rounded-full"></span>
51
- <div class="min-w-0 flex-1">
52
- <Markdown
53
- content={seg.text}
54
- streaming={running && seg.index === lastIndex}
55
- {collectionNames}
56
- />
57
- </div>
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 outcome}
63
- <p class="text-muted-foreground pl-4 font-mono text-xs tabular-nums">
64
- {#if outcome.error}
65
- {outcome.error}
66
- {:else}
67
- {outcome.turns} turns · {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
68
- {/if}
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
- rather than beside it, and the trailing control is a circular filled accent
6
- button. The empty→typing transition lives almost entirely in that control —
7
- the box chrome does not resize.
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
- The composer stays enabled while the agent works. Queuing the next message
10
- is not built yet; disabling the box would be a different and worse
11
- behaviour than the one being copied, so it is not disabled.
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
- if (value.trim()) onsubmit();
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
- class="border-border bg-surface-1 focus-within:border-primary/60 rounded-xl border transition-colors"
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
- <textarea
79
- bind:value
80
- onkeydown={keydown}
81
- rows="2"
82
- placeholder={running ? 'Queue another message…' : 'Ask Milton…'}
83
- class="text-foreground placeholder:text-muted-foreground max-h-52 w-full resize-none bg-transparent px-4 pt-3 pb-2 text-base outline-none"
84
- ></textarea>
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
- {choice.label}
99
- </button>
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
- </div>
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>