@poodle64/librarian 2026.9.6 → 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.
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 +63 -0
  5. package/dist/citations.js +89 -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 +166 -26
  11. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
  12. package/dist/components/composer/composer.svelte +206 -53
  13. package/dist/components/composer/composer.svelte.d.ts +5 -1
  14. package/dist/components/conversation/conversation.svelte +179 -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 +177 -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,28 @@
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
+ } from '../../transcript.svelte';
20
+ import {
21
+ citationMarkers,
22
+ resolveCitations,
23
+ splitSources,
24
+ type Citation
25
+ } from '../../citations';
10
26
  import Working from '../working/working.svelte';
11
27
  import ActivityGroup from '../activity-group/activity-group.svelte';
12
28
  import Markdown from '../markdown/markdown.svelte';
@@ -16,16 +32,76 @@
16
32
  blocks: Block[];
17
33
  outcome: Outcome | null;
18
34
  running: boolean;
35
+ /** Sources from the library's `citations` frame. Absent falls back to
36
+ * whatever a "## Sources" block in the prose says. */
37
+ citations?: Citation[];
19
38
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
20
39
  collectionNames?: Set<string>;
40
+ /** Opens the document pane. Omit and chips render but do not open. */
41
+ oncite?: (citation: Citation) => void;
42
+ /** Offered on the LAST answer only — re-asks the same question. */
43
+ onregenerate?: () => void;
21
44
  }
22
45
 
23
- let { question, blocks, outcome, running, collectionNames = new Set() }: Props = $props();
46
+ let {
47
+ question,
48
+ blocks,
49
+ outcome,
50
+ running,
51
+ citations = [],
52
+ collectionNames = new Set(),
53
+ oncite,
54
+ onregenerate
55
+ }: Props = $props();
24
56
 
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.
57
+ const asked = $derived(readerQuestion(question));
58
+
59
+ // An activity group stays live — and therefore labelled "Working…" — only
60
+ // while it is the last thing in the turn. As soon as prose arrives, it
61
+ // settles into its count.
27
62
  const segments = $derived(segment(blocks));
28
63
  const lastIndex = $derived(segments.at(-1)?.index ?? -1);
64
+
65
+ // The prose block a Sources list may be hiding at the end of is the last
66
+ // one; splitting every text segment would eat a "## Sources" heading a
67
+ // reader legitimately asked about mid-answer.
68
+ const answer = $derived(
69
+ segments
70
+ .filter((s) => s.kind === 'text')
71
+ .map((s) => s.text)
72
+ .join('\n\n')
73
+ );
74
+ const split = $derived(running ? { body: '', citations: [] } : splitSources(answer));
75
+ const sources = $derived(resolveCitations(citations, split.citations));
76
+ // Only a number the prose actually marks becomes a chip; a source list may
77
+ // legitimately carry an entry the answer never points at inline.
78
+ const numbers = $derived(
79
+ new Set(citationMarkers(answer).filter((n) => sources.some((c) => c.n === n)))
80
+ );
81
+
82
+ function prose(text: string, isLast: boolean): string {
83
+ // Only the last text segment can carry the trailing Sources block, and
84
+ // only once the answer has settled — mid-stream the heading may be all
85
+ // that has arrived.
86
+ return isLast && split.citations.length > 0 ? split.body : text;
87
+ }
88
+
89
+ const hasAnswer = $derived(segments.some((s) => s.kind === 'text'));
90
+ // A failed turn settles too, and "Ask again" is the one thing a reader wants
91
+ // from it — there is just nothing to copy.
92
+ const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
93
+
94
+ let copied = $state(false);
95
+ async function copy() {
96
+ try {
97
+ await navigator.clipboard.writeText(answer);
98
+ copied = true;
99
+ setTimeout(() => (copied = false), 1600);
100
+ } catch {
101
+ // A denied clipboard permission is not worth an error state on an
102
+ // answer the reader can still select by hand.
103
+ }
104
+ }
29
105
  </script>
30
106
 
31
107
  <article class="flex flex-col gap-3">
@@ -33,9 +109,9 @@
33
109
  panel: this surface is read by people who arrive with those two as
34
110
  their model of what a chat looks like. -->
35
111
  <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"
112
+ 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
113
  >
38
- {question}
114
+ {asked}
39
115
  </div>
40
116
 
41
117
  {#if running && segments.length === 0}
@@ -46,26 +122,90 @@
46
122
  {#if seg.kind === 'activity'}
47
123
  <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
48
124
  {: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>
125
+ <div class="max-w-[72ch] min-w-0">
126
+ <Markdown
127
+ content={prose(seg.text, seg.index === lastIndex)}
128
+ streaming={running && seg.index === lastIndex}
129
+ {collectionNames}
130
+ citationNumbers={numbers}
131
+ oncite={(n) => {
132
+ const found = sources.find((c) => c.n === n);
133
+ if (found) oncite?.(found);
134
+ }}
135
+ />
58
136
  </div>
59
137
  {/if}
60
138
  {/each}
61
139
 
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}
140
+ {#if sources.length > 0}
141
+ <section class="max-w-[72ch]">
142
+ <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
143
+ Sources
144
+ </h2>
145
+ <ol class="flex flex-col gap-1">
146
+ {#each sources as source (source.n)}
147
+ <li>
148
+ <button
149
+ type="button"
150
+ onclick={() => oncite?.(source)}
151
+ disabled={!source.document_id || !oncite}
152
+ 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"
153
+ >
154
+ <span
155
+ class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
156
+ >{source.n}</span
157
+ >
158
+ <span class="min-w-0">
159
+ <span class="text-foreground">{source.title}</span>
160
+ {#if source.section}<span class="text-muted-foreground">
161
+ · {source.section}</span
162
+ >{/if}
163
+ </span>
164
+ </button>
165
+ </li>
166
+ {/each}
167
+ </ol>
168
+ </section>
169
+ {/if}
170
+
171
+ {#if outcome?.error}
172
+ <p
173
+ class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
174
+ role="alert"
175
+ >
176
+ {outcome.error}
69
177
  </p>
70
178
  {/if}
179
+
180
+ {#if settled}
181
+ <div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
182
+ {#if hasAnswer}
183
+ <button
184
+ type="button"
185
+ onclick={copy}
186
+ aria-label={copied ? 'Copied' : 'Copy answer'}
187
+ 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"
188
+ >
189
+ {#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
190
+ <span>{copied ? 'Copied' : 'Copy'}</span>
191
+ </button>
192
+ {/if}
193
+ {#if onregenerate}
194
+ <button
195
+ type="button"
196
+ onclick={onregenerate}
197
+ aria-label="Ask again"
198
+ 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"
199
+ >
200
+ <RefreshCwIcon class="size-3.5" />
201
+ <span>Ask again</span>
202
+ </button>
203
+ {/if}
204
+ {#if outcome && !outcome.error}
205
+ <span class="pl-1 font-mono text-xs tabular-nums opacity-70">
206
+ {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
207
+ </span>
208
+ {/if}
209
+ </div>
210
+ {/if}
71
211
  </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,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
- if (value.trim()) onsubmit();
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
- class="border-border bg-surface-1 focus-within:border-primary/60 rounded-xl border transition-colors"
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
- <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'}"
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
- {choice.label}
99
- </button>
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
- </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>
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>