@ouraihub/aether-ui 0.0.0-stage → 0.1.1

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 (212) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +29 -0
  28. package/src/chat/ChatComposer.svelte +277 -0
  29. package/src/chat/ChatMessage.svelte +262 -0
  30. package/src/chat/ChatPanel.css +386 -0
  31. package/src/chat/ChatPanel.svelte +496 -0
  32. package/src/chat/ChatPanelApprovals.svelte +28 -0
  33. package/src/chat/ChatPanelComposer.svelte +317 -0
  34. package/src/chat/ChatPanelDialogs.svelte +242 -0
  35. package/src/chat/ChatPanelHeader.svelte +132 -0
  36. package/src/chat/ChatPanelMessages.svelte +166 -0
  37. package/src/chat/ChatPanelMode.svelte +190 -0
  38. package/src/chat/ChatPanelState.svelte.ts +1 -0
  39. package/src/chat/ChatPanelToolbar.svelte +425 -0
  40. package/src/chat/ChatSurface.svelte +168 -0
  41. package/src/chat/MessageItem.svelte +480 -0
  42. package/src/chat/ReasoningControl.svelte +286 -0
  43. package/src/chat/SkillPicker.svelte +133 -0
  44. package/src/chat/ToolCallItem.svelte +23 -0
  45. package/src/chat/UsageControl.svelte +216 -0
  46. package/src/chat/UsageDetails.svelte +76 -0
  47. package/src/chat/chat-attachments.svelte.ts +74 -0
  48. package/src/chat/chat-panel-history.svelte.ts +430 -0
  49. package/src/chat/chat-panel-props.ts +32 -0
  50. package/src/chat/index.ts +6 -0
  51. package/src/chat/run-session-history.ts +56 -0
  52. package/src/chat/run-session-types.ts +69 -0
  53. package/src/chat/run-session.svelte.ts +494 -0
  54. package/src/chat/test-support/chat-fixture.ts +178 -0
  55. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  56. package/src/chat/test-support/run-session-fixture.ts +112 -0
  57. package/src/components/ui/alert/alert-action.svelte +20 -0
  58. package/src/components/ui/alert/alert-description.svelte +23 -0
  59. package/src/components/ui/alert/alert-title.svelte +23 -0
  60. package/src/components/ui/alert/alert.svelte +43 -0
  61. package/src/components/ui/alert/index.ts +17 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  65. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  73. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  74. package/src/components/ui/alert-dialog/index.ts +40 -0
  75. package/src/components/ui/button/button.svelte +81 -0
  76. package/src/components/ui/button/index.ts +17 -0
  77. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  78. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  79. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  80. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  81. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  82. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  83. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  84. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  85. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  86. package/src/components/ui/dialog/dialog.svelte +7 -0
  87. package/src/components/ui/dialog/index.ts +34 -0
  88. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  89. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  90. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  91. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  92. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  93. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  96. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  97. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  98. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  99. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  100. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  101. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  102. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  103. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  104. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  105. package/src/components/ui/dropdown-menu/index.ts +54 -0
  106. package/src/components/ui/empty/empty-content.svelte +23 -0
  107. package/src/components/ui/empty/empty-description.svelte +23 -0
  108. package/src/components/ui/empty/empty-header.svelte +20 -0
  109. package/src/components/ui/empty/empty-media.svelte +41 -0
  110. package/src/components/ui/empty/empty-title.svelte +20 -0
  111. package/src/components/ui/empty/empty.svelte +23 -0
  112. package/src/components/ui/empty/index.ts +22 -0
  113. package/src/components/ui/field/field-content.svelte +20 -0
  114. package/src/components/ui/field/field-description.svelte +25 -0
  115. package/src/components/ui/field/field-error.svelte +58 -0
  116. package/src/components/ui/field/field-group.svelte +23 -0
  117. package/src/components/ui/field/field-label.svelte +25 -0
  118. package/src/components/ui/field/field-legend.svelte +24 -0
  119. package/src/components/ui/field/field-separator.svelte +35 -0
  120. package/src/components/ui/field/field-set.svelte +20 -0
  121. package/src/components/ui/field/field-title.svelte +20 -0
  122. package/src/components/ui/field/field.svelte +47 -0
  123. package/src/components/ui/field/index.ts +33 -0
  124. package/src/components/ui/input/index.ts +7 -0
  125. package/src/components/ui/input/input.svelte +48 -0
  126. package/src/components/ui/label/index.ts +7 -0
  127. package/src/components/ui/label/label.svelte +20 -0
  128. package/src/components/ui/select/index.ts +40 -0
  129. package/src/components/ui/select/select-content.svelte +45 -0
  130. package/src/components/ui/select/select-group-heading.svelte +21 -0
  131. package/src/components/ui/select/select-group.svelte +17 -0
  132. package/src/components/ui/select/select-item.svelte +43 -0
  133. package/src/components/ui/select/select-label.svelte +20 -0
  134. package/src/components/ui/select/select-portal.svelte +7 -0
  135. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  136. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  137. package/src/components/ui/select/select-separator.svelte +18 -0
  138. package/src/components/ui/select/select-trigger.svelte +29 -0
  139. package/src/components/ui/select/select-value.svelte +7 -0
  140. package/src/components/ui/select/select.svelte +11 -0
  141. package/src/components/ui/separator/index.ts +7 -0
  142. package/src/components/ui/separator/separator.svelte +21 -0
  143. package/src/components/ui/sonner/index.ts +1 -0
  144. package/src/components/ui/sonner/sonner.svelte +40 -0
  145. package/src/components/ui/switch/index.ts +7 -0
  146. package/src/components/ui/switch/switch.svelte +34 -0
  147. package/src/components/ui/textarea/index.ts +7 -0
  148. package/src/components/ui/textarea/textarea.svelte +22 -0
  149. package/src/context.ts +34 -0
  150. package/src/dev/Demo.svelte +47 -0
  151. package/src/dev/main.ts +7 -0
  152. package/src/i18n/en.ts +645 -0
  153. package/src/i18n/index.ts +28 -0
  154. package/src/i18n/zh-CN.ts +610 -0
  155. package/src/index.ts +10 -0
  156. package/src/logs/LogPanel.svelte +103 -0
  157. package/src/logs/LogSettings.svelte +58 -0
  158. package/src/logs/index.ts +5 -0
  159. package/src/logs/logger.ts +96 -0
  160. package/src/markdown.ts +8 -0
  161. package/src/mcp/McpClientEditor.svelte +204 -0
  162. package/src/mcp/McpClientSettings.svelte +418 -0
  163. package/src/mcp/McpServerSettings.svelte +363 -0
  164. package/src/mcp/config.ts +26 -0
  165. package/src/mcp/index.ts +3 -0
  166. package/src/mcp/input.ts +74 -0
  167. package/src/notify.svelte.ts +43 -0
  168. package/src/ports/contract.ts +174 -0
  169. package/src/ports/fake-optional.ts +81 -0
  170. package/src/ports/fake.ts +488 -0
  171. package/src/ports/index.ts +7 -0
  172. package/src/ports/search.ts +56 -0
  173. package/src/ports/skill-sources.ts +45 -0
  174. package/src/ports/test-support/fake-fixture.ts +63 -0
  175. package/src/providers/EndpointKeysDialog.svelte +357 -0
  176. package/src/providers/EndpointModels.svelte +311 -0
  177. package/src/providers/EndpointPanel.svelte +266 -0
  178. package/src/providers/ModelPickerDialog.svelte +230 -0
  179. package/src/providers/ModelPricing.svelte +125 -0
  180. package/src/providers/ModelSelect.svelte +448 -0
  181. package/src/providers/ModelSettingsDialog.svelte +388 -0
  182. package/src/providers/ProviderDialogs.svelte +120 -0
  183. package/src/providers/ProviderList.svelte +159 -0
  184. package/src/providers/ProvidersSettings.svelte +491 -0
  185. package/src/providers/RequestConfigDialog.svelte +278 -0
  186. package/src/providers/SecretInput.svelte +284 -0
  187. package/src/providers/draft.ts +355 -0
  188. package/src/providers/edit-session.ts +170 -0
  189. package/src/providers/index.ts +5 -0
  190. package/src/providers/reasoning.ts +55 -0
  191. package/src/providers/revision.svelte.ts +6 -0
  192. package/src/providers/test-support/providers-fixture.ts +120 -0
  193. package/src/search/SearchAdvancedTools.svelte +182 -0
  194. package/src/search/SearchProviderCard.svelte +195 -0
  195. package/src/search/SearchSettings.svelte +500 -0
  196. package/src/search/index.ts +13 -0
  197. package/src/search/settings-flow.ts +67 -0
  198. package/src/session.ts +20 -0
  199. package/src/skills/SkillAdd.svelte +380 -0
  200. package/src/skills/SkillAuthoring.svelte +430 -0
  201. package/src/skills/SkillEditor.svelte +104 -0
  202. package/src/skills/SkillPreviewDialog.svelte +136 -0
  203. package/src/skills/SkillsSettings.svelte +147 -0
  204. package/src/skills/error.ts +32 -0
  205. package/src/skills/index.ts +4 -0
  206. package/src/skills/test-support/skills-fixture.ts +139 -0
  207. package/src/styles/kit.css +137 -0
  208. package/src/styles/tokens.css +22 -0
  209. package/src/tauri/index.ts +298 -0
  210. package/src/utils.ts +22 -0
  211. package/src/web/index.ts +441 -0
  212. package/README.md +0 -3
@@ -0,0 +1,425 @@
1
+ <script lang="ts">
2
+ import { 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
+ const visiblePinned = $derived(pinned.filter((id) => tools.includes(id)))
59
+ /** Tools usable here; unavailable pinned ids stay stored but hidden (docs/38 §3.2). */
60
+ const tools = $derived(
61
+ ['new-conversation', 'web-search', 'attachment', 'generate-image'].filter(
62
+ (id) =>
63
+ (id !== 'web-search' || (hasSearch && purpose === 'chat')) &&
64
+ (id !== 'generate-image' || purpose === 'chat'),
65
+ ),
66
+ )
67
+ const toolOrder = $derived([
68
+ ...pinned.filter((id) => tools.includes(id)),
69
+ ...tools.filter((id) => !pinned.includes(id)),
70
+ ])
71
+ function setPinned(next: string[]) {
72
+ const all = [...next, ...pinned.filter((id) => !tools.includes(id))]
73
+ if (all.join() === pinned.join()) return
74
+ pinned = all
75
+ onToolbarChange?.(all)
76
+ }
77
+ function moveTool(id: string, to: number) {
78
+ const order = toolOrder.filter((item) => item !== id)
79
+ order.splice(Math.max(0, Math.min(to, order.length)), 0, id)
80
+ setPinned(order.filter((item) => pinned.includes(item)))
81
+ }
82
+ // Window listeners: keyed reordering moves the row node, which would drop pointer capture.
83
+ function dragTool(event: PointerEvent, id: string) {
84
+ const list = (event.currentTarget as HTMLElement).closest('ul')
85
+ if (!list) return
86
+ event.preventDefault()
87
+ const move = (e: PointerEvent) => {
88
+ const over = [...list.children].findIndex((row) => {
89
+ const box = row.getBoundingClientRect()
90
+ return e.clientY >= box.top && e.clientY < box.bottom
91
+ })
92
+ if (over >= 0 && toolOrder[over] !== id) moveTool(id, over)
93
+ }
94
+ const stop = () => {
95
+ window.removeEventListener('pointermove', move)
96
+ window.removeEventListener('pointerup', stop)
97
+ window.removeEventListener('pointercancel', stop)
98
+ }
99
+ window.addEventListener('pointermove', move)
100
+ window.addEventListener('pointerup', stop)
101
+ window.addEventListener('pointercancel', stop)
102
+ }
103
+ const TOOL_LABEL: Record<string, string> = {
104
+ 'new-conversation': 'chat.newConversation',
105
+ 'web-search': 'chat.network',
106
+ attachment: 'chat.uploadAttachment',
107
+ 'generate-image': 'chat.generateImage',
108
+ }
109
+ </script>
110
+
111
+ {#snippet toolIcon(id: string)}
112
+ {#if id === 'new-conversation'}<MessageSquarePlusIcon />{:else if id === 'web-search'}<GlobeIcon
113
+ >{#if !webSearch}<path d="m2 2 20 20" />{/if}</GlobeIcon
114
+ >{:else if id === 'attachment'}<PaperclipIcon />{:else}<ImageIcon />{/if}
115
+ {/snippet}
116
+ {#each visiblePinned as id (id)}
117
+ {#if id === 'new-conversation'}<Button
118
+ type="button"
119
+ variant="ghost"
120
+ size="icon-sm"
121
+ disabled={running || historyBusy}
122
+ aria-label={t('chat.newConversation')}
123
+ title={t('chat.newConversation')}
124
+ onclick={newConversation}>{@render toolIcon(id)}</Button
125
+ >{:else if id === 'web-search'}<Button
126
+ type="button"
127
+ variant="ghost"
128
+ class="search-toggle"
129
+ size="icon-sm"
130
+ aria-label={t('chat.network')}
131
+ aria-pressed={webSearch}
132
+ disabled={running}
133
+ title={`${t(webSearch ? 'chat.networkOn' : 'chat.networkOff')} · ${t('chat.networkPrivacy')}`}
134
+ onclick={() => (webSearch = !webSearch)}>{@render toolIcon(id)}</Button
135
+ >{:else if id === 'attachment'}<Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="icon-sm"
139
+ aria-label={t('chat.uploadAttachment')}
140
+ disabled={running || !vision}
141
+ title={vision ? t('chat.uploadAttachment') : t('chat.visionUnsupported')}
142
+ onclick={() => fileInput?.click()}>{@render toolIcon(id)}</Button
143
+ >{:else}<Button
144
+ type="button"
145
+ variant="ghost"
146
+ class="search-toggle"
147
+ size="icon-sm"
148
+ aria-label={t('chat.generateImage')}
149
+ aria-pressed={generateImage}
150
+ disabled={running || !paintingModel || capability?.supportsTools === false}
151
+ title={!paintingModel
152
+ ? t('chat.paintingUnconfigured')
153
+ : capability?.supportsTools === false
154
+ ? t('chat.toolsUnsupported')
155
+ : t('chat.generateImage')}
156
+ onclick={() => (generateImage = !generateImage)}>{@render toolIcon(id)}</Button
157
+ >{/if}
158
+ {/each}
159
+ <input
160
+ bind:this={fileInput}
161
+ type="file"
162
+ accept={IMAGE_MIMES.join(',')}
163
+ multiple
164
+ hidden
165
+ onchange={(e) => {
166
+ const input = e.currentTarget
167
+ void attach(input.files).finally(() => (input.value = ''))
168
+ }}
169
+ />
170
+ <Popover.Root bind:open={toolsOpen} onOpenChange={(open) => !open && (customizing = false)}>
171
+ <Popover.Trigger disabled={running}>
172
+ {#snippet child({ props })}<Button
173
+ {...props}
174
+ type="button"
175
+ variant="ghost"
176
+ size="icon-sm"
177
+ aria-label={t('chat.tools')}
178
+ title={t('chat.tools')}><PlusIcon /></Button
179
+ >{/snippet}
180
+ </Popover.Trigger>
181
+ <Popover.Portal>
182
+ <Popover.Content
183
+ class="tools-menu"
184
+ side="top"
185
+ align="start"
186
+ sideOffset={6}
187
+ collisionPadding={8}
188
+ >
189
+ {#if customizing}
190
+ <p class="tools-title" id="{reasoningId}-toolbar">{t('chat.toolbarCustomize')}</p>
191
+ <ul class="tools-order" aria-labelledby="{reasoningId}-toolbar">
192
+ {#each toolOrder as id, index (id)}
193
+ <li class="tools-row">
194
+ <button
195
+ type="button"
196
+ class="tools-grip"
197
+ aria-label={t('chat.toolbarDrag', { name: t(TOOL_LABEL[id] ?? id) })}
198
+ onpointerdown={(e) => dragTool(e, id)}
199
+ onkeydown={(e) => {
200
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
201
+ e.preventDefault()
202
+ const button = e.currentTarget
203
+ moveTool(id, index + (e.key === 'ArrowUp' ? -1 : 1))
204
+ void tick().then(() => button.focus())
205
+ }}><GripVerticalIcon /></button
206
+ >
207
+ {@render toolIcon(id)}<span>{t(TOOL_LABEL[id] ?? id)}</span>
208
+ <Switch
209
+ size="sm"
210
+ aria-label={t(TOOL_LABEL[id] ?? id)}
211
+ checked={pinned.includes(id)}
212
+ onCheckedChange={(on) =>
213
+ setPinned(toolOrder.filter((item) => (item === id ? on : pinned.includes(item))))}
214
+ />
215
+ </li>
216
+ {/each}
217
+ </ul>
218
+ <button
219
+ type="button"
220
+ class="tools-item tools-footer"
221
+ disabled={pinned.join() === DEFAULT_TOOLBAR.join()}
222
+ onclick={() => {
223
+ pinned = [...DEFAULT_TOOLBAR]
224
+ onToolbarChange?.(pinned)
225
+ }}><RotateCcwIcon /><span>{t('chat.toolbarRestore')}</span></button
226
+ >
227
+ {:else}
228
+ {#each toolOrder.filter((id) => !visiblePinned.includes(id)) as id (id)}
229
+ {#if id === 'new-conversation'}<button
230
+ type="button"
231
+ class="tools-item"
232
+ disabled={historyBusy}
233
+ onclick={() => {
234
+ toolsOpen = false
235
+ newConversation()
236
+ }}>{@render toolIcon(id)}<span>{t('chat.newConversation')}</span></button
237
+ >{:else if id === 'web-search'}<button
238
+ type="button"
239
+ class="tools-item"
240
+ aria-pressed={webSearch}
241
+ onclick={() => {
242
+ toolsOpen = false
243
+ webSearch = !webSearch
244
+ }}
245
+ >{@render toolIcon(id)}<span>{t('chat.network')}</span>{#if webSearch}<CheckIcon
246
+ />{/if}</button
247
+ >{:else if id === 'attachment'}<button
248
+ type="button"
249
+ class="tools-item"
250
+ disabled={!vision}
251
+ title={vision ? undefined : t('chat.visionUnsupported')}
252
+ onclick={() => {
253
+ toolsOpen = false
254
+ fileInput?.click()
255
+ }}
256
+ >{@render toolIcon(id)}<span>{t('chat.uploadAttachment')}</span>{#if !vision}<small
257
+ >{t('chat.visionUnsupported')}</small
258
+ >{/if}</button
259
+ >{:else}<button
260
+ type="button"
261
+ class="tools-item"
262
+ aria-pressed={generateImage}
263
+ disabled={!paintingModel || capability?.supportsTools === false}
264
+ title={!paintingModel
265
+ ? t('chat.paintingUnconfigured')
266
+ : capability?.supportsTools === false
267
+ ? t('chat.toolsUnsupported')
268
+ : undefined}
269
+ onclick={() => {
270
+ toolsOpen = false
271
+ generateImage = !generateImage
272
+ }}
273
+ >{@render toolIcon(id)}<span>{t('chat.generateImage')}</span
274
+ >{#if !paintingModel}<small>{t('chat.paintingUnconfigured')}</small
275
+ >{:else if generateImage}<CheckIcon />{/if}</button
276
+ >{/if}
277
+ {/each}
278
+ <button
279
+ type="button"
280
+ class="tools-item tools-footer"
281
+ aria-label={t('chat.toolbarCustomize')}
282
+ onclick={() => (customizing = true)}
283
+ ><span>{t('chat.quickPanel')}</span><Settings2Icon /></button
284
+ >
285
+ {/if}
286
+ </Popover.Content>
287
+ </Popover.Portal>
288
+ </Popover.Root>
289
+ {#if generateImage && paintingModel && purpose === 'chat' && !pinned.includes('generate-image')}<Button
290
+ type="button"
291
+ variant="secondary"
292
+ size="sm"
293
+ class="tool-chip"
294
+ aria-label={t('chat.generateImageOff')}
295
+ title={t('chat.generateImageOff')}
296
+ onclick={() => (generateImage = false)}><ImageIcon />{t('chat.generateImage')}<XIcon /></Button
297
+ >{/if}
298
+
299
+ <style>
300
+ :global(button.search-toggle) {
301
+ color: var(--color-muted-foreground);
302
+ }
303
+ :global(button.search-toggle[aria-pressed='true']) {
304
+ color: var(--color-primary-foreground);
305
+ background: var(--color-primary);
306
+ border-color: var(--color-primary);
307
+ }
308
+ :global(button.search-toggle[aria-pressed='true']:hover:not(:disabled)) {
309
+ background: color-mix(in oklch, var(--color-primary) 85%, var(--color-foreground));
310
+ }
311
+ :global(.tools-menu) {
312
+ z-index: 50;
313
+ box-sizing: border-box;
314
+ width: min(16rem, calc(100vw - 1rem));
315
+ display: flex;
316
+ flex-direction: column;
317
+ padding: 0.25rem;
318
+ border: 1px solid var(--color-border);
319
+ border-radius: var(--kit-radius, var(--radius));
320
+ background: var(--color-popover);
321
+ color: var(--color-popover-foreground);
322
+ box-shadow: 0 4px 12px color-mix(in oklch, var(--color-foreground) 12%, transparent);
323
+ font-size: var(--kit-font-size-sm, 0.8125rem);
324
+ }
325
+ :global(.tools-item) {
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 0.5rem;
329
+ min-height: 2.25rem;
330
+ padding: 0.25rem 0.625rem;
331
+ border: 0;
332
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
333
+ background: transparent;
334
+ color: inherit;
335
+ font: inherit;
336
+ text-align: left;
337
+ cursor: pointer;
338
+ }
339
+ :global(.tools-item:hover:not(:disabled)),
340
+ :global(.tools-item:focus-visible) {
341
+ background: var(--color-accent);
342
+ color: var(--color-accent-foreground);
343
+ outline: none;
344
+ }
345
+ :global(.tools-item:disabled) {
346
+ cursor: not-allowed;
347
+ opacity: 0.55;
348
+ }
349
+ :global(.tools-item svg) {
350
+ width: 1rem;
351
+ height: 1rem;
352
+ flex-shrink: 0;
353
+ }
354
+ :global(.tools-item small) {
355
+ margin-left: auto;
356
+ color: var(--color-muted-foreground);
357
+ font-size: 0.75rem;
358
+ }
359
+ :global(.tools-item > svg:last-child:not(:first-child)) {
360
+ margin-left: auto;
361
+ }
362
+ :global(.tools-footer) {
363
+ margin-top: 0.25rem;
364
+ border-top: 1px solid var(--color-border);
365
+ border-radius: 0 0 calc(var(--kit-radius, var(--radius)) - 2px)
366
+ calc(var(--kit-radius, var(--radius)) - 2px);
367
+ color: var(--color-muted-foreground);
368
+ }
369
+ :global(.tools-title) {
370
+ margin: 0;
371
+ padding: 0.375rem 0.5rem;
372
+ font-size: 0.875rem;
373
+ font-weight: 500;
374
+ }
375
+ :global(.tools-order) {
376
+ display: flex;
377
+ flex-direction: column;
378
+ gap: 0.125rem;
379
+ margin: 0;
380
+ padding: 0;
381
+ list-style: none;
382
+ }
383
+ :global(.tools-row) {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 0.5rem;
387
+ min-height: 2rem;
388
+ padding: 0 0.375rem;
389
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
390
+ font-size: 0.875rem;
391
+ }
392
+ :global(.tools-row:hover) {
393
+ background: var(--color-accent);
394
+ }
395
+ :global(.tools-row > svg) {
396
+ width: 1rem;
397
+ height: 1rem;
398
+ flex-shrink: 0;
399
+ color: var(--color-muted-foreground);
400
+ }
401
+ :global(.tools-row > span) {
402
+ flex: 1;
403
+ min-width: 0;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ white-space: nowrap;
407
+ }
408
+ :global(.tools-grip) {
409
+ display: inline-flex;
410
+ padding: 0.125rem;
411
+ border: 0;
412
+ border-radius: 0.25rem;
413
+ background: none;
414
+ color: var(--color-muted-foreground);
415
+ cursor: grab;
416
+ touch-action: none;
417
+ }
418
+ :global(.tools-grip:focus-visible) {
419
+ outline: 2px solid var(--color-ring);
420
+ }
421
+ :global(.tools-grip svg) {
422
+ width: 0.875rem;
423
+ height: 0.875rem;
424
+ }
425
+ </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>