@ouraihub/aether-ui 0.1.0 → 0.1.2

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 (90) hide show
  1. package/package.json +3 -3
  2. package/src/chat/ApprovalCard.svelte +8 -2
  3. package/src/chat/ChatComposer.svelte +319 -0
  4. package/src/chat/ChatMessage.svelte +262 -0
  5. package/src/chat/ChatPanel.css +386 -0
  6. package/src/chat/ChatPanel.svelte +276 -1880
  7. package/src/chat/ChatPanelApprovals.svelte +28 -0
  8. package/src/chat/ChatPanelComposer.svelte +336 -0
  9. package/src/chat/ChatPanelDialogs.svelte +242 -0
  10. package/src/chat/ChatPanelHeader.svelte +108 -0
  11. package/src/chat/ChatPanelMessages.svelte +166 -0
  12. package/src/chat/ChatPanelMode.svelte +190 -0
  13. package/src/chat/ChatPanelState.svelte.ts +1 -0
  14. package/src/chat/ChatPanelToolbar.svelte +436 -0
  15. package/src/chat/ChatSurface.svelte +168 -0
  16. package/src/chat/ContextUsageStatus.svelte +97 -0
  17. package/src/chat/MessageItem.svelte +111 -214
  18. package/src/chat/ModelEffortPicker.svelte +326 -0
  19. package/src/chat/PickerEffort.svelte +71 -0
  20. package/src/chat/ReasoningControl.svelte +184 -0
  21. package/src/chat/ReasoningSlider.svelte +174 -0
  22. package/src/chat/SkillPicker.svelte +14 -6
  23. package/src/chat/UsageControl.svelte +223 -0
  24. package/src/chat/UsageDetails.svelte +44 -90
  25. package/src/chat/chat-attachments.svelte.ts +78 -0
  26. package/src/chat/chat-panel-history.svelte.ts +430 -0
  27. package/src/chat/chat-panel-models.svelte.ts +97 -0
  28. package/src/chat/chat-panel-props.ts +38 -0
  29. package/src/chat/context-status.ts +101 -0
  30. package/src/chat/icons/LICENSE-REMIX +186 -0
  31. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  32. package/src/chat/icons/PathIcon.svelte +8 -0
  33. package/src/chat/icons/effort-icons.ts +65 -0
  34. package/src/chat/icons/status-icons.ts +105 -0
  35. package/src/chat/index.ts +25 -1
  36. package/src/chat/model-effort.ts +177 -0
  37. package/src/chat/run-session-history.ts +56 -0
  38. package/src/chat/run-session-types.ts +69 -0
  39. package/src/chat/run-session.svelte.ts +73 -135
  40. package/src/chat/test-support/chat-fixture.ts +194 -0
  41. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  42. package/src/chat/test-support/run-session-fixture.ts +112 -0
  43. package/src/dev/Demo.svelte +3 -1
  44. package/src/i18n/en-chat.d.ts +1 -0
  45. package/src/i18n/en-chat.js +201 -0
  46. package/src/i18n/en.ts +58 -180
  47. package/src/i18n/index.ts +7 -2
  48. package/src/i18n/zh-CN-chat.d.ts +1 -0
  49. package/src/i18n/zh-CN-chat.js +190 -0
  50. package/src/i18n/zh-CN.ts +8 -153
  51. package/src/logs/LogPanel.svelte +9 -3
  52. package/src/logs/LogSettings.svelte +3 -1
  53. package/src/mcp/McpClientEditor.svelte +204 -0
  54. package/src/mcp/McpClientSettings.svelte +57 -130
  55. package/src/mcp/McpServerSettings.svelte +47 -11
  56. package/src/mcp/input.ts +2 -1
  57. package/src/ports/contract.ts +11 -2
  58. package/src/ports/fake-optional.ts +81 -0
  59. package/src/ports/fake.ts +90 -86
  60. package/src/ports/search.ts +11 -2
  61. package/src/ports/test-support/fake-fixture.ts +63 -0
  62. package/src/providers/EndpointKeysDialog.svelte +27 -13
  63. package/src/providers/EndpointModels.svelte +311 -0
  64. package/src/providers/EndpointPanel.svelte +33 -258
  65. package/src/providers/ModelPickerDialog.svelte +59 -16
  66. package/src/providers/ModelPricing.svelte +125 -0
  67. package/src/providers/ModelSelect.svelte +22 -8
  68. package/src/providers/ModelSettingsDialog.svelte +61 -126
  69. package/src/providers/ProviderDialogs.svelte +120 -0
  70. package/src/providers/ProviderList.svelte +159 -0
  71. package/src/providers/ProvidersSettings.svelte +67 -207
  72. package/src/providers/RequestConfigDialog.svelte +40 -12
  73. package/src/providers/SecretInput.svelte +11 -3
  74. package/src/providers/draft.ts +39 -13
  75. package/src/providers/edit-session.ts +31 -7
  76. package/src/providers/reasoning.ts +18 -4
  77. package/src/providers/revision.svelte.ts +3 -1
  78. package/src/providers/test-support/providers-fixture.ts +120 -0
  79. package/src/search/SearchAdvancedTools.svelte +182 -0
  80. package/src/search/SearchProviderCard.svelte +195 -0
  81. package/src/search/SearchSettings.svelte +62 -265
  82. package/src/search/settings-flow.ts +8 -3
  83. package/src/skills/SkillAdd.svelte +51 -18
  84. package/src/skills/SkillAuthoring.svelte +69 -20
  85. package/src/skills/SkillEditor.svelte +13 -3
  86. package/src/skills/SkillPreviewDialog.svelte +19 -5
  87. package/src/skills/SkillsSettings.svelte +11 -4
  88. package/src/skills/test-support/skills-fixture.ts +139 -0
  89. package/src/tauri/index.ts +23 -7
  90. package/src/web/index.ts +90 -25
@@ -0,0 +1,436 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte'
3
+ import { Popover } from 'bits-ui'
4
+ import PlusIcon from '@lucide/svelte/icons/plus'
5
+ import GlobeIcon from '@lucide/svelte/icons/globe'
6
+ import PaperclipIcon from '@lucide/svelte/icons/paperclip'
7
+ import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus'
8
+ import ImageIcon from '@lucide/svelte/icons/image'
9
+ import CheckIcon from '@lucide/svelte/icons/check'
10
+ import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical'
11
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
12
+ import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
13
+ import XIcon from '@lucide/svelte/icons/x'
14
+ import { Button } from '#lib/components/ui/button/index.js'
15
+ import { Switch } from '#lib/components/ui/switch/index.js'
16
+ import type { KitI18n } from '../i18n'
17
+ import type { ModelOption } from '../ports'
18
+ let {
19
+ i18n,
20
+ running,
21
+ historyBusy,
22
+ vision,
23
+ paintingModel,
24
+ capability,
25
+ purpose,
26
+ toolbar,
27
+ hasSearch,
28
+ onToolbarChange,
29
+ newConversation,
30
+ attach,
31
+ webSearch = $bindable(false),
32
+ generateImage = $bindable(false),
33
+ }: {
34
+ i18n: KitI18n
35
+ running: boolean
36
+ historyBusy: boolean
37
+ vision: boolean
38
+ paintingModel: boolean
39
+ capability: ModelOption | null
40
+ purpose: string
41
+ hasSearch: boolean
42
+ toolbar?: string[] | undefined
43
+ onToolbarChange?: ((ids: string[]) => void) | undefined
44
+ newConversation: () => void
45
+ attach: (files: FileList | null) => Promise<void>
46
+ webSearch?: boolean
47
+ generateImage?: boolean
48
+ } = $props()
49
+ const t = $derived(i18n.t)
50
+ const reasoningId = $props.id()
51
+ const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
52
+ let fileInput = $state<HTMLInputElement | null>(null)
53
+ let toolsOpen = $state(false)
54
+ let customizing = $state(false)
55
+ const DEFAULT_TOOLBAR = ['new-conversation', 'web-search']
56
+ // svelte-ignore state_referenced_locally
57
+ let pinned = $state<string[]>(toolbar ?? DEFAULT_TOOLBAR)
58
+ let compact = $state(false)
59
+ const visiblePinned = $derived(compact ? [] : pinned.filter((id) => tools.includes(id)))
60
+ onMount(() => {
61
+ const shell = fileInput?.closest('.composer-shell')
62
+ if (!shell) return
63
+ const observer = new ResizeObserver(([entry]) => {
64
+ if (entry && entry.contentRect.width > 0) compact = entry.contentRect.width < 400
65
+ })
66
+ observer.observe(shell)
67
+ return () => observer.disconnect()
68
+ })
69
+ /** Tools usable here; unavailable pinned ids stay stored but hidden (docs/38 §3.2). */
70
+ const tools = $derived(
71
+ ['new-conversation', 'web-search', 'attachment', 'generate-image'].filter(
72
+ (id) =>
73
+ (id !== 'web-search' || (hasSearch && purpose === 'chat')) &&
74
+ (id !== 'generate-image' || purpose === 'chat'),
75
+ ),
76
+ )
77
+ const toolOrder = $derived([
78
+ ...pinned.filter((id) => tools.includes(id)),
79
+ ...tools.filter((id) => !pinned.includes(id)),
80
+ ])
81
+ function setPinned(next: string[]) {
82
+ const all = [...next, ...pinned.filter((id) => !tools.includes(id))]
83
+ if (all.join() === pinned.join()) return
84
+ pinned = all
85
+ onToolbarChange?.(all)
86
+ }
87
+ function moveTool(id: string, to: number) {
88
+ const order = toolOrder.filter((item) => item !== id)
89
+ order.splice(Math.max(0, Math.min(to, order.length)), 0, id)
90
+ setPinned(order.filter((item) => pinned.includes(item)))
91
+ }
92
+ // Window listeners: keyed reordering moves the row node, which would drop pointer capture.
93
+ function dragTool(event: PointerEvent, id: string) {
94
+ const list = (event.currentTarget as HTMLElement).closest('ul')
95
+ if (!list) return
96
+ event.preventDefault()
97
+ const move = (e: PointerEvent) => {
98
+ const over = [...list.children].findIndex((row) => {
99
+ const box = row.getBoundingClientRect()
100
+ return e.clientY >= box.top && e.clientY < box.bottom
101
+ })
102
+ if (over >= 0 && toolOrder[over] !== id) moveTool(id, over)
103
+ }
104
+ const stop = () => {
105
+ window.removeEventListener('pointermove', move)
106
+ window.removeEventListener('pointerup', stop)
107
+ window.removeEventListener('pointercancel', stop)
108
+ }
109
+ window.addEventListener('pointermove', move)
110
+ window.addEventListener('pointerup', stop)
111
+ window.addEventListener('pointercancel', stop)
112
+ }
113
+ const TOOL_LABEL: Record<string, string> = {
114
+ 'new-conversation': 'chat.newConversation',
115
+ 'web-search': 'chat.network',
116
+ attachment: 'chat.uploadAttachment',
117
+ 'generate-image': 'chat.generateImage',
118
+ }
119
+ </script>
120
+
121
+ {#snippet toolIcon(id: string)}
122
+ {#if id === 'new-conversation'}<MessageSquarePlusIcon />{:else if id === 'web-search'}<GlobeIcon
123
+ >{#if !webSearch}<path d="m2 2 20 20" />{/if}</GlobeIcon
124
+ >{:else if id === 'attachment'}<PaperclipIcon />{:else}<ImageIcon />{/if}
125
+ {/snippet}
126
+ {#each visiblePinned as id (id)}
127
+ {#if id === 'new-conversation'}<Button
128
+ type="button"
129
+ variant="ghost"
130
+ size="icon-sm"
131
+ disabled={running || historyBusy}
132
+ aria-label={t('chat.newConversation')}
133
+ title={t('chat.newConversation')}
134
+ onclick={newConversation}>{@render toolIcon(id)}</Button
135
+ >{:else if id === 'web-search'}<Button
136
+ type="button"
137
+ variant="ghost"
138
+ class="search-toggle"
139
+ size="icon-sm"
140
+ aria-label={t('chat.network')}
141
+ aria-pressed={webSearch}
142
+ disabled={running}
143
+ title={`${t(webSearch ? 'chat.networkOn' : 'chat.networkOff')} · ${t('chat.networkPrivacy')}`}
144
+ onclick={() => (webSearch = !webSearch)}>{@render toolIcon(id)}</Button
145
+ >{:else if id === 'attachment'}<Button
146
+ type="button"
147
+ variant="ghost"
148
+ size="icon-sm"
149
+ aria-label={t('chat.uploadAttachment')}
150
+ disabled={running || !vision}
151
+ title={vision ? t('chat.uploadAttachment') : t('chat.visionUnsupported')}
152
+ onclick={() => fileInput?.click()}>{@render toolIcon(id)}</Button
153
+ >{:else}<Button
154
+ type="button"
155
+ variant="ghost"
156
+ class="search-toggle"
157
+ size="icon-sm"
158
+ aria-label={t('chat.generateImage')}
159
+ aria-pressed={generateImage}
160
+ disabled={running || !paintingModel || capability?.supportsTools === false}
161
+ title={!paintingModel
162
+ ? t('chat.paintingUnconfigured')
163
+ : capability?.supportsTools === false
164
+ ? t('chat.toolsUnsupported')
165
+ : t('chat.generateImage')}
166
+ onclick={() => (generateImage = !generateImage)}>{@render toolIcon(id)}</Button
167
+ >{/if}
168
+ {/each}
169
+ <input
170
+ bind:this={fileInput}
171
+ type="file"
172
+ accept={IMAGE_MIMES.join(',')}
173
+ multiple
174
+ hidden
175
+ onchange={(e) => {
176
+ const input = e.currentTarget
177
+ void attach(input.files).finally(() => (input.value = ''))
178
+ }}
179
+ />
180
+ <Popover.Root bind:open={toolsOpen} onOpenChange={(open) => !open && (customizing = false)}>
181
+ <Popover.Trigger disabled={running}>
182
+ {#snippet child({ props })}<Button
183
+ {...props}
184
+ type="button"
185
+ variant="ghost"
186
+ size="icon-sm"
187
+ aria-label={t('chat.tools')}
188
+ title={t('chat.tools')}><PlusIcon /></Button
189
+ >{/snippet}
190
+ </Popover.Trigger>
191
+ <Popover.Portal>
192
+ <Popover.Content
193
+ class="tools-menu"
194
+ side="top"
195
+ align="start"
196
+ sideOffset={6}
197
+ collisionPadding={8}
198
+ >
199
+ {#if customizing}
200
+ <p class="tools-title" id="{reasoningId}-toolbar">{t('chat.toolbarCustomize')}</p>
201
+ <ul class="tools-order" aria-labelledby="{reasoningId}-toolbar">
202
+ {#each toolOrder as id, index (id)}
203
+ <li class="tools-row">
204
+ <button
205
+ type="button"
206
+ class="tools-grip"
207
+ aria-label={t('chat.toolbarDrag', { name: t(TOOL_LABEL[id] ?? id) })}
208
+ onpointerdown={(e) => dragTool(e, id)}
209
+ onkeydown={(e) => {
210
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
211
+ e.preventDefault()
212
+ const button = e.currentTarget
213
+ moveTool(id, index + (e.key === 'ArrowUp' ? -1 : 1))
214
+ void tick().then(() => button.focus())
215
+ }}><GripVerticalIcon /></button
216
+ >
217
+ {@render toolIcon(id)}<span>{t(TOOL_LABEL[id] ?? id)}</span>
218
+ <Switch
219
+ size="sm"
220
+ aria-label={t(TOOL_LABEL[id] ?? id)}
221
+ checked={pinned.includes(id)}
222
+ onCheckedChange={(on) =>
223
+ setPinned(toolOrder.filter((item) => (item === id ? on : pinned.includes(item))))}
224
+ />
225
+ </li>
226
+ {/each}
227
+ </ul>
228
+ <button
229
+ type="button"
230
+ class="tools-item tools-footer"
231
+ disabled={pinned.join() === DEFAULT_TOOLBAR.join()}
232
+ onclick={() => {
233
+ pinned = [...DEFAULT_TOOLBAR]
234
+ onToolbarChange?.(pinned)
235
+ }}><RotateCcwIcon /><span>{t('chat.toolbarRestore')}</span></button
236
+ >
237
+ {:else}
238
+ {#each toolOrder.filter((id) => !visiblePinned.includes(id)) as id (id)}
239
+ {#if id === 'new-conversation'}<button
240
+ type="button"
241
+ class="tools-item"
242
+ disabled={historyBusy}
243
+ onclick={() => {
244
+ toolsOpen = false
245
+ newConversation()
246
+ }}>{@render toolIcon(id)}<span>{t('chat.newConversation')}</span></button
247
+ >{:else if id === 'web-search'}<button
248
+ type="button"
249
+ class="tools-item"
250
+ aria-pressed={webSearch}
251
+ onclick={() => {
252
+ toolsOpen = false
253
+ webSearch = !webSearch
254
+ }}
255
+ >{@render toolIcon(id)}<span>{t('chat.network')}</span>{#if webSearch}<CheckIcon
256
+ />{/if}</button
257
+ >{:else if id === 'attachment'}<button
258
+ type="button"
259
+ class="tools-item"
260
+ disabled={!vision}
261
+ title={vision ? undefined : t('chat.visionUnsupported')}
262
+ onclick={() => {
263
+ toolsOpen = false
264
+ fileInput?.click()
265
+ }}
266
+ >{@render toolIcon(id)}<span>{t('chat.uploadAttachment')}</span>{#if !vision}<small
267
+ >{t('chat.visionUnsupported')}</small
268
+ >{/if}</button
269
+ >{:else}<button
270
+ type="button"
271
+ class="tools-item"
272
+ aria-pressed={generateImage}
273
+ disabled={!paintingModel || capability?.supportsTools === false}
274
+ title={!paintingModel
275
+ ? t('chat.paintingUnconfigured')
276
+ : capability?.supportsTools === false
277
+ ? t('chat.toolsUnsupported')
278
+ : undefined}
279
+ onclick={() => {
280
+ toolsOpen = false
281
+ generateImage = !generateImage
282
+ }}
283
+ >{@render toolIcon(id)}<span>{t('chat.generateImage')}</span
284
+ >{#if !paintingModel}<small>{t('chat.paintingUnconfigured')}</small
285
+ >{:else if generateImage}<CheckIcon />{/if}</button
286
+ >{/if}
287
+ {/each}
288
+ <button
289
+ type="button"
290
+ class="tools-item tools-footer"
291
+ aria-label={t('chat.toolbarCustomize')}
292
+ onclick={() => (customizing = true)}
293
+ ><span>{t('chat.quickPanel')}</span><Settings2Icon /></button
294
+ >
295
+ {/if}
296
+ </Popover.Content>
297
+ </Popover.Portal>
298
+ </Popover.Root>
299
+ {#if generateImage && paintingModel && purpose === 'chat' && !pinned.includes('generate-image')}
300
+ <Button
301
+ type="button"
302
+ variant="secondary"
303
+ size="sm"
304
+ class="tool-chip"
305
+ aria-label={t('chat.generateImageOff')}
306
+ title={t('chat.generateImageOff')}
307
+ onclick={() => (generateImage = false)}><ImageIcon />{t('chat.generateImage')}<XIcon /></Button
308
+ >{/if}
309
+
310
+ <style>
311
+ :global(button.search-toggle) {
312
+ color: var(--color-muted-foreground);
313
+ }
314
+ :global(button.search-toggle[aria-pressed='true']) {
315
+ color: var(--color-primary-foreground);
316
+ background: var(--color-primary);
317
+ border-color: var(--color-primary);
318
+ }
319
+ :global(button.search-toggle[aria-pressed='true']:hover:not(:disabled)) {
320
+ background: color-mix(in oklch, var(--color-primary) 85%, var(--color-foreground));
321
+ }
322
+ :global(.tools-menu) {
323
+ z-index: 50;
324
+ box-sizing: border-box;
325
+ width: min(16rem, calc(100vw - 1rem));
326
+ display: flex;
327
+ flex-direction: column;
328
+ padding: 0.25rem;
329
+ border: 1px solid var(--color-border);
330
+ border-radius: var(--kit-radius, var(--radius));
331
+ background: var(--color-popover);
332
+ color: var(--color-popover-foreground);
333
+ box-shadow: 0 4px 12px color-mix(in oklch, var(--color-foreground) 12%, transparent);
334
+ font-size: var(--kit-font-size-sm, 0.8125rem);
335
+ }
336
+ :global(.tools-item) {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 0.5rem;
340
+ min-height: 2.25rem;
341
+ padding: 0.25rem 0.625rem;
342
+ border: 0;
343
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
344
+ background: transparent;
345
+ color: inherit;
346
+ font: inherit;
347
+ text-align: left;
348
+ cursor: pointer;
349
+ }
350
+ :global(.tools-item:hover:not(:disabled)),
351
+ :global(.tools-item:focus-visible) {
352
+ background: var(--color-accent);
353
+ color: var(--color-accent-foreground);
354
+ outline: none;
355
+ }
356
+ :global(.tools-item:disabled) {
357
+ cursor: not-allowed;
358
+ opacity: 0.55;
359
+ }
360
+ :global(.tools-item svg) {
361
+ width: 1rem;
362
+ height: 1rem;
363
+ flex-shrink: 0;
364
+ }
365
+ :global(.tools-item small) {
366
+ margin-left: auto;
367
+ color: var(--color-muted-foreground);
368
+ font-size: 0.75rem;
369
+ }
370
+ :global(.tools-item > svg:last-child:not(:first-child)) {
371
+ margin-left: auto;
372
+ }
373
+ :global(.tools-footer) {
374
+ margin-top: 0.25rem;
375
+ border-top: 1px solid var(--color-border);
376
+ border-radius: 0 0 calc(var(--kit-radius, var(--radius)) - 2px)
377
+ calc(var(--kit-radius, var(--radius)) - 2px);
378
+ color: var(--color-muted-foreground);
379
+ }
380
+ :global(.tools-title) {
381
+ margin: 0;
382
+ padding: 0.375rem 0.5rem;
383
+ font-size: 0.875rem;
384
+ font-weight: 500;
385
+ }
386
+ :global(.tools-order) {
387
+ display: flex;
388
+ flex-direction: column;
389
+ gap: 0.125rem;
390
+ margin: 0;
391
+ padding: 0;
392
+ list-style: none;
393
+ }
394
+ :global(.tools-row) {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 0.5rem;
398
+ min-height: 2rem;
399
+ padding: 0 0.375rem;
400
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
401
+ font-size: 0.875rem;
402
+ }
403
+ :global(.tools-row:hover) {
404
+ background: var(--color-accent);
405
+ }
406
+ :global(.tools-row > svg) {
407
+ width: 1rem;
408
+ height: 1rem;
409
+ flex-shrink: 0;
410
+ color: var(--color-muted-foreground);
411
+ }
412
+ :global(.tools-row > span) {
413
+ flex: 1;
414
+ min-width: 0;
415
+ overflow: hidden;
416
+ text-overflow: ellipsis;
417
+ white-space: nowrap;
418
+ }
419
+ :global(.tools-grip) {
420
+ display: inline-flex;
421
+ padding: 0.125rem;
422
+ border: 0;
423
+ border-radius: 0.25rem;
424
+ background: none;
425
+ color: var(--color-muted-foreground);
426
+ cursor: grab;
427
+ touch-action: none;
428
+ }
429
+ :global(.tools-grip:focus-visible) {
430
+ outline: 2px solid var(--color-ring);
431
+ }
432
+ :global(.tools-grip svg) {
433
+ width: 0.875rem;
434
+ height: 0.875rem;
435
+ }
436
+ </style>
@@ -0,0 +1,168 @@
1
+ <script lang="ts">
2
+ import { tick, untrack, type Snippet } from 'svelte'
3
+ import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
4
+ import { Button } from '#lib/components/ui/button/index.js'
5
+
6
+ let {
7
+ label,
8
+ messagesLabel = label,
9
+ header,
10
+ children,
11
+ composer,
12
+ navigation,
13
+ followKey,
14
+ scrollKey,
15
+ latestLabel = 'Back to latest',
16
+ class: className = '',
17
+ }: {
18
+ label: string
19
+ messagesLabel?: string | undefined
20
+ header?: Snippet | undefined
21
+ children: Snippet
22
+ composer: Snippet
23
+ navigation?: Snippet | undefined
24
+ followKey?: string | number | undefined
25
+ scrollKey?: string | undefined
26
+ latestLabel?: string | undefined
27
+ class?: string | undefined
28
+ } = $props()
29
+ let scroller = $state<HTMLDivElement | null>(null)
30
+ let follow = $state(true)
31
+ let lastScrollTop = 0
32
+ let viewportHeight = $state(0)
33
+ let contentHeight = $state(0)
34
+ function toBottom() {
35
+ follow = true
36
+ if (scroller) {
37
+ scroller.scrollTop = scroller.scrollHeight
38
+ lastScrollTop = scroller.scrollTop
39
+ }
40
+ }
41
+ function onscroll() {
42
+ if (!scroller) return
43
+ follow =
44
+ scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 48 &&
45
+ scroller.scrollTop >= lastScrollTop
46
+ lastScrollTop = scroller.scrollTop
47
+ }
48
+ $effect.pre(() => {
49
+ void followKey
50
+ void viewportHeight
51
+ void contentHeight
52
+ if (untrack(() => follow))
53
+ void tick().then(() => {
54
+ if (follow) toBottom()
55
+ })
56
+ })
57
+ $effect(() => {
58
+ void scrollKey
59
+ follow = true
60
+ void tick().then(toBottom)
61
+ })
62
+ </script>
63
+
64
+ <section class="chat-surface {className}" aria-label={label}>
65
+ {#if navigation}<aside class="chat-navigation">{@render navigation()}</aside>{/if}
66
+ <div class="chat-main">
67
+ {#if header}<div class="surface-header">{@render header()}</div>{/if}
68
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex (keyboard-accessible scroll region) -->
69
+ <div
70
+ class="messages"
71
+ bind:this={scroller}
72
+ bind:clientHeight={viewportHeight}
73
+ {onscroll}
74
+ role="region"
75
+ aria-label={messagesLabel}
76
+ tabindex="0"
77
+ >
78
+ <div class="message-column" bind:clientHeight={contentHeight}>{@render children()}</div>
79
+ </div>
80
+ {#if !follow}<div class="bottom-control">
81
+ <Button type="button" variant="secondary" size="sm" onclick={toBottom}
82
+ ><ArrowDownIcon data-icon="inline-start" />{latestLabel}</Button
83
+ >
84
+ </div>{/if}
85
+ <div class="surface-composer">{@render composer()}</div>
86
+ </div>
87
+ </section>
88
+
89
+ <style>
90
+ /* Hallmark · component: conversation surface · theme: host semantic tokens */
91
+ .chat-surface {
92
+ display: flex;
93
+ flex: 1;
94
+ min-width: 0;
95
+ min-height: 0;
96
+ height: 100%;
97
+ container-type: inline-size;
98
+ color: var(--color-foreground);
99
+ }
100
+ .chat-main {
101
+ display: flex;
102
+ flex: 1;
103
+ flex-direction: column;
104
+ min-width: 0;
105
+ min-height: 0;
106
+ position: relative;
107
+ container-type: size;
108
+ }
109
+ .chat-navigation {
110
+ flex: 0 0 240px;
111
+ min-height: 0;
112
+ overflow-y: auto;
113
+ border-inline-end: 1px solid var(--color-border);
114
+ }
115
+ .surface-header {
116
+ flex: none;
117
+ padding: 0.75rem 1rem;
118
+ border-bottom: 1px solid var(--color-border);
119
+ }
120
+ .messages {
121
+ flex: 1;
122
+ min-height: 0;
123
+ min-width: 0;
124
+ overflow-y: auto;
125
+ overflow-x: clip;
126
+ overscroll-behavior: contain;
127
+ overflow-anchor: none;
128
+ }
129
+ .messages:focus-visible {
130
+ outline: 2px solid var(--color-ring);
131
+ outline-offset: -2px;
132
+ }
133
+ .message-column,
134
+ .surface-composer {
135
+ box-sizing: border-box;
136
+ width: 100%;
137
+ max-width: 848px;
138
+ margin-inline: auto;
139
+ padding: 1.5rem;
140
+ }
141
+ .surface-composer {
142
+ flex: none;
143
+ max-height: 70cqh;
144
+ overflow-y: auto;
145
+ padding-top: 0.5rem;
146
+ padding-bottom: 1rem;
147
+ }
148
+ .bottom-control {
149
+ display: flex;
150
+ flex: none;
151
+ justify-content: center;
152
+ padding-block: 0.25rem;
153
+ }
154
+ @container (max-width: 899px) {
155
+ .chat-navigation {
156
+ display: none;
157
+ }
158
+ }
159
+ @container (max-width: 480px) {
160
+ .message-column,
161
+ .surface-composer {
162
+ padding-inline: 0.75rem;
163
+ }
164
+ .surface-header {
165
+ padding-inline: 0.75rem;
166
+ }
167
+ }
168
+ </style>
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ // Inline context status beside Send (docs/31 §4.3); opens the shared usage popover.
3
+ import type { Snippet } from 'svelte'
4
+ import { cn } from '#lib/utils.js'
5
+ import type { KitI18n } from '../i18n'
6
+ import type { ContextUsage } from '../ports'
7
+ import UsageControl from './UsageControl.svelte'
8
+ import PathIcon from './icons/PathIcon.svelte'
9
+ import {
10
+ detectPlatform,
11
+ pressure,
12
+ spending,
13
+ summary as contextSummary,
14
+ type Platform,
15
+ type SpendingTurn,
16
+ } from './context-status'
17
+ import { applePath, linuxPath, wandPath, windowsPath } from './icons/status-icons'
18
+
19
+ let {
20
+ context,
21
+ contextWindow = null,
22
+ turns,
23
+ modelName = '',
24
+ i18n,
25
+ platform = detectPlatform(),
26
+ settings,
27
+ details,
28
+ open = $bindable(false),
29
+ }: {
30
+ context: ContextUsage | null
31
+ /** Fallback capacity, e.g. the selected model's window, when the usage has none. */
32
+ contextWindow?: number | null
33
+ turns: readonly SpendingTurn[]
34
+ modelName?: string
35
+ i18n: KitI18n
36
+ platform?: Platform
37
+ settings?: Snippet | undefined
38
+ details?: Snippet | undefined
39
+ open?: boolean
40
+ } = $props()
41
+ const marks: Record<Platform, { name: string; path: string } | null> = {
42
+ windows: { name: 'Windows', path: windowsPath },
43
+ macos: { name: 'macOS', path: applePath },
44
+ linux: { name: 'Linux', path: linuxPath },
45
+ other: null,
46
+ }
47
+ const capacity = $derived(context?.contextWindow ?? contextWindow)
48
+ const text = $derived(contextSummary(context, capacity))
49
+ const spent = $derived(spending(turns))
50
+ const level = $derived(pressure(context, capacity))
51
+ const mark = $derived(marks[platform])
52
+ const label = $derived(
53
+ i18n
54
+ .t('chat.status.label', { platform: mark?.name ?? '', summary: text, spending: spent })
55
+ .trim(),
56
+ )
57
+ </script>
58
+
59
+ <UsageControl
60
+ bind:open
61
+ {i18n}
62
+ {settings}
63
+ {details}
64
+ summaryLabel={label}
65
+ context={{
66
+ tokens: context?.tokens ?? null,
67
+ contextWindow: capacity,
68
+ modelName,
69
+ source: context?.source ?? 'unknown',
70
+ }}
71
+ >
72
+ {#snippet summary()}
73
+ <span
74
+ class={cn(
75
+ 'inline-flex min-w-0 items-center gap-1.5 font-mono text-xs tabular-nums',
76
+ level === 'normal' && 'text-muted-foreground',
77
+ level === 'warning' && 'text-warning',
78
+ level === 'high' &&
79
+ 'text-[color-mix(in_oklch,var(--color-warning)_45%,var(--color-destructive))]',
80
+ level === 'critical' && 'text-destructive',
81
+ )}
82
+ data-pressure={level}
83
+ >
84
+ {#if mark}<PathIcon
85
+ data-composer-capacity
86
+ d={mark.path}
87
+ class="size-3 shrink-0 text-foreground"
88
+ />{/if}
89
+ <span class="shrink-0 whitespace-nowrap"
90
+ >{text.split('/')[0]}<span data-composer-capacity>/{text.split('/')[1]}</span></span
91
+ >
92
+ <PathIcon d={wandPath} class="size-3 shrink-0" />
93
+ <span data-composer-capacity class="mx-1.5 shrink-0 text-border" aria-hidden="true">·</span>
94
+ <span data-composer-capacity class="min-w-0 truncate text-muted-foreground">{spent}</span>
95
+ </span>
96
+ {/snippet}
97
+ </UsageControl>