@ouraihub/aether-ui 0.0.0-stage → 0.1.0

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 (179) 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 +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,2097 @@
1
+ <script lang="ts">
2
+ import { onDestroy, tick, untrack } from 'svelte'
3
+ import { SvelteMap } from 'svelte/reactivity'
4
+ import XIcon from '@lucide/svelte/icons/x'
5
+ import PlusIcon from '@lucide/svelte/icons/plus'
6
+ import HistoryIcon from '@lucide/svelte/icons/history'
7
+ import GlobeIcon from '@lucide/svelte/icons/globe'
8
+ import MaximizeIcon from '@lucide/svelte/icons/maximize-2'
9
+ import MinimizeIcon from '@lucide/svelte/icons/minimize-2'
10
+ import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
11
+ import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
12
+ import SquareIcon from '@lucide/svelte/icons/square'
13
+ import GaugeIcon from '@lucide/svelte/icons/gauge'
14
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
15
+ import PaperclipIcon from '@lucide/svelte/icons/paperclip'
16
+ import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus'
17
+ import ImageIcon from '@lucide/svelte/icons/image'
18
+ import CheckIcon from '@lucide/svelte/icons/check'
19
+ import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical'
20
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
21
+ import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
22
+ import ShieldIcon from '@lucide/svelte/icons/shield'
23
+ import HandIcon from '@lucide/svelte/icons/hand'
24
+ import RouteIcon from '@lucide/svelte/icons/route'
25
+ import FilePenIcon from '@lucide/svelte/icons/file-pen'
26
+ import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
27
+ import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert'
28
+ import InfoIcon from '@lucide/svelte/icons/info'
29
+ import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'
30
+ import { Switch } from '#lib/components/ui/switch/index.js'
31
+ import { Popover } from 'bits-ui'
32
+ import { Button } from '#lib/components/ui/button/index.js'
33
+ import { Input } from '#lib/components/ui/input/index.js'
34
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
35
+ import * as Empty from '#lib/components/ui/empty/index.js'
36
+ import * as Alert from '#lib/components/ui/alert/index.js'
37
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
38
+ import * as Field from '#lib/components/ui/field/index.js'
39
+ import { getKitContext } from '../context'
40
+ import {
41
+ isKitClientError,
42
+ type ConversationSummary,
43
+ type Conversation,
44
+ type ModelRef,
45
+ type SkillRef,
46
+ type ModelOption,
47
+ type ImagePart,
48
+ type ImageRef,
49
+ type ReasoningEffort,
50
+ type PermissionMode,
51
+ } from '../ports'
52
+ import ModelSelect from '../providers/ModelSelect.svelte'
53
+ import { providersRevision, settingsTarget } from '../providers/revision.svelte'
54
+ import { RunSession, type ChatTurn } from './run-session.svelte'
55
+ import UsageDetails from './UsageDetails.svelte'
56
+ import MessageItem from './MessageItem.svelte'
57
+ import { nearestEffort, REASONING_EFFORTS } from '../providers/reasoning'
58
+ import SkillPicker from './SkillPicker.svelte'
59
+
60
+ let {
61
+ purpose = 'chat',
62
+ model = null,
63
+ scope,
64
+ context = null,
65
+ skills,
66
+ onSkillsChange,
67
+ skillsBudget = 48_000,
68
+ onInsert,
69
+ onSaveNote,
70
+ onExportMarkdown,
71
+ onOpenSettings,
72
+ conversationId = null,
73
+ onConversationChange,
74
+ onClose,
75
+ paintingModel = false,
76
+ toolbar: toolbarProp,
77
+ onToolbarChange,
78
+ reasoningEffort: reasoningEffortProp,
79
+ onReasoningEffortChange,
80
+ permissionModes = ['default', 'plan', 'accept_edits'],
81
+ permissionMode = $bindable('default'),
82
+ }: {
83
+ purpose?: string
84
+ model?: ModelRef | null
85
+ scope?: string
86
+ /** Host context can be removed for the next message without changing conversation ownership. */
87
+ context?: { kind: string; id: string; label: string } | null
88
+ skills?: SkillRef[]
89
+ onSkillsChange?: (refs: SkillRef[]) => void
90
+ skillsBudget?: number
91
+ onInsert?: (markdown: string) => void
92
+ onSaveNote?: (markdown: string, images: ImageRef[]) => Promise<void> | void
93
+ onExportMarkdown?: (markdown: string) => Promise<void> | void
94
+ onOpenSettings?: () => void
95
+ /** A failed restore stays visible until retried or explicitly replaced with a new conversation. */
96
+ conversationId?: string | null
97
+ onConversationChange?: (id: string | null) => void
98
+ onClose?: () => void
99
+ /** Whether the host has a painting model bound; gates “Generate image” (docs/20 §1). */
100
+ paintingModel?: boolean
101
+ /** Ordered ids of composer tools pinned to the toolbar (docs/38); omitted means the default. */
102
+ toolbar?: string[]
103
+ onToolbarChange?: (ids: string[]) => void
104
+ /** Saved effort choice (host preference); omitted means `default`. Applied per model as its nearest allowed effort. */
105
+ reasoningEffort?: ReasoningEffort
106
+ onReasoningEffortChange?: (effort: ReasoningEffort) => void
107
+ /** Modes the host allows for this panel's runs (same list as `KitHost::permission_modes`). */
108
+ permissionModes?: PermissionMode[]
109
+ /** Bindable non-dangerous preference; full access stays private and is consumed once. */
110
+ permissionMode?: PermissionMode
111
+ } = $props()
112
+
113
+ const { ports, i18n, notify } = getKitContext()
114
+ let viewGeneration = 0
115
+ let listGeneration = 0
116
+ let disposed = false
117
+ const { t } = i18n
118
+ const history = ports.conversations
119
+ const session = ports.chat ? new RunSession(ports.chat, history) : null
120
+ // svelte-ignore state_referenced_locally
121
+ let picked = $state<ModelRef | null>(model)
122
+ let modelOptions = $state<ModelOption[] | null>(null)
123
+ let modelError = $state<string | null>(null)
124
+ let modelReload = $state(0)
125
+ const capability = $derived(
126
+ modelOptions?.find((item) => item.endpointId === picked?.endpointId && item.modelId === picked?.modelId) ?? null,
127
+ )
128
+ // The saved choice survives model switches; each model sends its nearest allowed effort (docs/28 RT3).
129
+ // svelte-ignore state_referenced_locally
130
+ let chosenEffort = $state<ReasoningEffort>(reasoningEffortProp ?? 'default')
131
+ const allowedEfforts = $derived(REASONING_EFFORTS.filter((effort) => capability?.reasoning?.efforts.includes(effort)))
132
+ const reasoningEffort = $derived(nearestEffort(chosenEffort, allowedEfforts))
133
+ function chooseEffort(effort: ReasoningEffort) {
134
+ if (session?.running || effort === chosenEffort) return
135
+ chosenEffort = effort
136
+ onReasoningEffortChange?.(effort)
137
+ }
138
+ const MODES: PermissionMode[] = ['default', 'plan', 'accept_edits', 'auto', 'full_access']
139
+ const allowedModes = $derived(MODES.filter((mode) => permissionModes.includes(mode)))
140
+ let fullAccess = $state(false)
141
+ const mode: PermissionMode = $derived(
142
+ fullAccess && allowedModes.includes('full_access')
143
+ ? 'full_access'
144
+ : permissionMode !== 'full_access' && allowedModes.includes(permissionMode)
145
+ ? permissionMode
146
+ : (allowedModes.find((option) => option !== 'full_access') ?? 'default'),
147
+ )
148
+ async function chooseMode(next: PermissionMode) {
149
+ if (session?.running || next === mode || !allowedModes.includes(next)) return
150
+ const generation = viewGeneration
151
+ if (next === 'full_access') {
152
+ if (
153
+ !(await notify.confirm({
154
+ title: t('chat.mode.fullAccessTitle'),
155
+ body: t('chat.mode.fullAccessBody'),
156
+ danger: true,
157
+ }))
158
+ )
159
+ return
160
+ if (!disposed && generation === viewGeneration && !session?.running && allowedModes.includes(next))
161
+ fullAccess = true
162
+ } else {
163
+ fullAccess = false
164
+ permissionMode = next
165
+ }
166
+ }
167
+ let reasoningOpen = $state(false)
168
+ const reasoningId = $props.id()
169
+ let reasoningTrigger = $state<HTMLButtonElement | null>(null)
170
+ const effortLabel = $derived(t(`chat.effort.${reasoningEffort}`))
171
+ const effortIndex = $derived(
172
+ reasoningEffort === 'default' ? Math.floor(allowedEfforts.length / 2) : allowedEfforts.indexOf(reasoningEffort),
173
+ )
174
+ const noModels = $derived(modelOptions !== null && modelOptions.length === 0)
175
+ const errorText = (error: unknown) => t(`errors.${isKitClientError(error) ? error.code : 'internal'}`)
176
+ $effect(() => {
177
+ void modelReload
178
+ void providersRevision.value
179
+ let alive = true
180
+ // A provider save swaps options in place: nulling them would reset the chosen effort (docs/41 §3.5).
181
+ modelError = null
182
+ if (!ports.providers) {
183
+ modelOptions = []
184
+ return
185
+ }
186
+ void ports.providers.modelOptions({ modelType: 'chat' }).then(
187
+ (list) => {
188
+ if (alive) modelOptions = list
189
+ },
190
+ (error: unknown) => {
191
+ if (alive) modelError = errorText(error)
192
+ },
193
+ )
194
+ return () => {
195
+ alive = false
196
+ }
197
+ })
198
+ $effect(() => {
199
+ // A model that turns out not to take tools cannot keep a pending generate_image request.
200
+ if (capability?.supportsTools === false) generateImage = false
201
+ })
202
+ function commitReasoning(event: Event) {
203
+ const effort = allowedEfforts[Number((event.currentTarget as HTMLInputElement).value)]
204
+ if (effort) chooseEffort(effort)
205
+ }
206
+ function pickModel(next: ModelRef) {
207
+ picked = next
208
+ session?.resetContext()
209
+ }
210
+
211
+ let draft = $state('')
212
+ let draftRevision = 0
213
+ let contextRevision = 0
214
+ let expanded = $state(false)
215
+ // svelte-ignore state_referenced_locally
216
+ let ownSkills = $state<SkillRef[]>(skills ?? [])
217
+ const selected = $derived(skills ?? ownSkills)
218
+ function setSkills(refs: SkillRef[]) {
219
+ ownSkills = refs
220
+ onSkillsChange?.(refs)
221
+ }
222
+ let droppedKey = $state<string | null>(null)
223
+ const contextKey = $derived(context ? `${context.kind}:${context.id}` : null)
224
+ const activeContext = $derived(context && contextKey !== droppedKey ? context : null)
225
+ let webSearch = $state(false)
226
+ let generateImage = $state(false)
227
+ const base = $derived({
228
+ purpose,
229
+ scope: scope ?? null,
230
+ language: i18n.locale,
231
+ model: picked,
232
+ skills: selected,
233
+ reasoningEffort,
234
+ permissionMode: mode,
235
+ generateImage: purpose === 'chat' && generateImage && paintingModel,
236
+ context: activeContext ? { kind: activeContext.kind, id: activeContext.id } : null,
237
+ })
238
+ // Consume before awaiting run acceptance, including retries and translations. Never persist full access.
239
+ function consumeBase() {
240
+ const request = { ...base }
241
+ fullAccess = false
242
+ return request
243
+ }
244
+ const currentContext = $derived(
245
+ session?.contextUsage &&
246
+ session.contextUsage.endpointId === picked?.endpointId &&
247
+ session.contextUsage.modelId === picked?.modelId
248
+ ? session.contextUsage
249
+ : null,
250
+ )
251
+
252
+ let conversations = $state<ConversationSummary[]>([])
253
+ let shownSummary = $state<ConversationSummary | null>(null)
254
+ const title = $derived(
255
+ (shownSummary?.id === session?.conversationId ? shownSummary?.title : null) ??
256
+ conversations.find((c) => c.id === session?.conversationId)?.title ??
257
+ t('chat.newConversation'),
258
+ )
259
+ let historyOpen = $state(false)
260
+ let historyBusy = $state(false)
261
+ let historyLoading = $state(false)
262
+ let historyError = $state<string | null>(null)
263
+ let retryHistory: () => Promise<void> = refresh
264
+ let renameTitle = $state('')
265
+ let renaming = $state(false)
266
+ // svelte-ignore state_referenced_locally
267
+ let reported = conversationId
268
+ let restored = $state(false)
269
+ let restoreBlocked = $state(false)
270
+ $effect(() => () => {
271
+ disposed = true
272
+ viewGeneration++
273
+ listGeneration++
274
+ fullAccess = false
275
+ session?.dispose()
276
+ })
277
+ $effect(() => {
278
+ if (session && !session.running) void refresh()
279
+ })
280
+ $effect(() => {
281
+ const id = session?.conversationId ?? null
282
+ if (!restored || restoreBlocked || id === reported) return
283
+ reported = id
284
+ onConversationChange?.(id)
285
+ })
286
+ // svelte-ignore state_referenced_locally
287
+ void restore(conversationId)
288
+
289
+ async function refresh() {
290
+ if (!history) return
291
+ const generation = ++listGeneration
292
+ historyLoading = true
293
+ try {
294
+ const next = await history.list(scope)
295
+ if (disposed || generation !== listGeneration) return
296
+ conversations = next
297
+ if (retryHistory === refresh) historyError = null
298
+ } catch (error) {
299
+ if (!disposed && generation === listGeneration && !restoreBlocked) {
300
+ historyError = errorText(error)
301
+ retryHistory = refresh
302
+ }
303
+ } finally {
304
+ if (!disposed && generation === listGeneration) historyLoading = false
305
+ }
306
+ }
307
+ async function restore(id: string | null) {
308
+ if (id !== null && history && session) {
309
+ restoreBlocked = true
310
+ await open(id)
311
+ }
312
+ restored = true
313
+ }
314
+ async function open(id: string) {
315
+ if (!history || !session || session.running || historyBusy) return
316
+ const generation = ++viewGeneration
317
+ fullAccess = false
318
+ historyBusy = true
319
+ try {
320
+ const conversation = await history.get(id)
321
+ if (disposed || generation !== viewGeneration) return
322
+ session.show(id, conversation.turns, conversation.summary.selectedTurnId)
323
+ shownSummary = conversation.summary
324
+ restoreBlocked = false
325
+ restored = true
326
+ historyError = null
327
+ retryHistory = refresh
328
+ historyOpen = false
329
+ renaming = false
330
+ follow = true
331
+ } catch (error) {
332
+ if (disposed || generation !== viewGeneration) return
333
+ historyError = errorText(error)
334
+ retryHistory = () => open(id)
335
+ } finally {
336
+ if (!disposed && generation === viewGeneration) historyBusy = false
337
+ }
338
+ }
339
+ function newConversation() {
340
+ if (!session || session.running || historyBusy) return
341
+ viewGeneration++
342
+ fullAccess = false
343
+ session.show(null)
344
+ shownSummary = null
345
+ restoreBlocked = false
346
+ restored = true
347
+ historyError = null
348
+ historyOpen = false
349
+ renaming = false
350
+ follow = true
351
+ }
352
+ async function rename() {
353
+ const id = session?.conversationId
354
+ const next = renameTitle.trim()
355
+ if (!history || !id || !next || session?.running || historyBusy) return
356
+ const generation = viewGeneration
357
+ historyBusy = true
358
+ try {
359
+ const summary = await history.rename(id, next)
360
+ if (disposed || generation !== viewGeneration) return
361
+ shownSummary = summary
362
+ conversations = conversations.map((c) => (c.id === id ? summary : c))
363
+ renaming = false
364
+ historyError = null
365
+ } catch (error) {
366
+ if (disposed || generation !== viewGeneration) return
367
+ historyError = errorText(error)
368
+ retryHistory = rename
369
+ } finally {
370
+ if (!disposed && generation === viewGeneration) historyBusy = false
371
+ }
372
+ }
373
+ async function remove() {
374
+ const id = session?.conversationId
375
+ if (!history || !session || !id || session.running || historyBusy) return
376
+ const generation = viewGeneration
377
+ historyBusy = true
378
+ try {
379
+ const ok = await notify.confirm({ title: t('chat.deleteTitle'), body: t('chat.deleteBody'), danger: true })
380
+ if (!ok || disposed || generation !== viewGeneration) return
381
+ await history.remove(id)
382
+ if (disposed || generation !== viewGeneration) return
383
+ if (session.conversationId === id) {
384
+ session.show(null)
385
+ shownSummary = null
386
+ historyOpen = false
387
+ renaming = false
388
+ follow = true
389
+ }
390
+ historyError = null
391
+ void refresh()
392
+ } catch (error) {
393
+ if (disposed || generation !== viewGeneration) return
394
+ historyError = errorText(error)
395
+ retryHistory = remove
396
+ } finally {
397
+ if (!disposed && generation === viewGeneration) historyBusy = false
398
+ }
399
+ }
400
+
401
+ const groups = $derived.by(() => {
402
+ const grouped = new SvelteMap<string, ChatTurn[]>()
403
+ for (const turn of session?.turns ?? []) {
404
+ const replies = grouped.get(turn.questionId)
405
+ if (replies) replies.push(turn)
406
+ else grouped.set(turn.questionId, [turn])
407
+ }
408
+ return [...grouped.entries()]
409
+ })
410
+ const actionBusy = $derived(historyBusy || session?.running === true || restoreBlocked)
411
+ let replyDialog = $state<'edit' | 'translate' | null>(null)
412
+ let replyOpen = $state(false)
413
+ let sourceReply = $state<ChatTurn | null>(null)
414
+ let editDraft = $state('')
415
+ let expectedText = ''
416
+ let targetLanguage = $state('')
417
+ let replyModel = $state<ModelRef | null>(null)
418
+ let replyError = $state<string | null>(null)
419
+ const validReplyModel = $derived(
420
+ modelOptions?.some(
421
+ (option) => option.endpointId === replyModel?.endpointId && option.modelId === replyModel?.modelId,
422
+ ) === true,
423
+ )
424
+ function sourceModel(turn: ChatTurn): ModelRef | null {
425
+ return turn.endpointId && turn.modelId ? { endpointId: turn.endpointId, modelId: turn.modelId } : null
426
+ }
427
+ function sourceLabel(turn: ChatTurn) {
428
+ const option = modelOptions?.find((item) => item.endpointId === turn.endpointId && item.modelId === turn.modelId)
429
+ return option
430
+ ? (option.name ?? option.modelId)
431
+ : turn.modelId
432
+ ? `${turn.endpointId ?? t('chat.unknown')} · ${turn.modelId}`
433
+ : t('chat.noSourceModel')
434
+ }
435
+ function hasChildren(turn: ChatTurn) {
436
+ return session?.turns.some((child) => child.parentId === turn.id || child.supersedesId === turn.id) === true
437
+ }
438
+ function canEdit(turn: ChatTurn) {
439
+ return turn.status !== 'running' && !hasChildren(turn)
440
+ }
441
+ function editReply(turn: ChatTurn) {
442
+ sourceReply = turn
443
+ editDraft = turn.text
444
+ expectedText = turn.text
445
+ replyError = null
446
+ replyDialog = 'edit'
447
+ replyOpen = true
448
+ }
449
+ function translateDialog(turn: ChatTurn) {
450
+ sourceReply = turn
451
+ replyModel = sourceModel(turn)
452
+ targetLanguage = i18n.locale
453
+ replyError = null
454
+ replyDialog = 'translate'
455
+ replyOpen = true
456
+ }
457
+ function showConversation(conversation: Conversation) {
458
+ viewGeneration++
459
+ fullAccess = false
460
+ session?.show(conversation.summary.id, conversation.turns, conversation.summary.selectedTurnId)
461
+ shownSummary = conversation.summary
462
+ follow = true
463
+ }
464
+ async function mutateReply(turn: ChatTurn, action: 'select' | 'remove' | 'branch' | 'copy') {
465
+ const id = session?.conversationId
466
+ if (!history || !session || !id || actionBusy) return
467
+ let generation = viewGeneration
468
+ historyBusy = true
469
+ try {
470
+ if (action === 'remove') {
471
+ if (turn.status === 'running') return
472
+ const reparent = session.turns.some((child) => child.parentId === turn.id)
473
+ if (
474
+ !(await notify.confirm({
475
+ title: t('chat.deleteReplyTitle'),
476
+ body: reparent ? t('chat.deleteReparentBody') : '',
477
+ danger: true,
478
+ }))
479
+ )
480
+ return
481
+ }
482
+ if (disposed || generation !== viewGeneration) return
483
+ const conversation =
484
+ action === 'select' || action === 'branch'
485
+ ? await history.selectTurn(id, turn.id)
486
+ : action === 'remove'
487
+ ? await history.removeTurn(id, turn.id)
488
+ : await history.fork(id, turn.id)
489
+ if (disposed || generation !== viewGeneration) return
490
+ showConversation(conversation)
491
+ generation = viewGeneration
492
+ if (action === 'branch') {
493
+ draft = ''
494
+ draftRevision++
495
+ discardRecovery()
496
+ }
497
+ historyError = null
498
+ void refresh()
499
+ } catch (error) {
500
+ if (disposed || generation !== viewGeneration) return
501
+ notify.toast('error', errorText(error))
502
+ } finally {
503
+ if (!disposed && generation === viewGeneration) historyBusy = false
504
+ }
505
+ }
506
+ async function retryReply(turn: ChatTurn) {
507
+ if (!session || actionBusy) return
508
+ const source = sourceModel(turn)
509
+ if (history && turn.persisted) {
510
+ if (modelOptions === null || modelError) {
511
+ notify.toast('error', modelError ?? t('chat.modelsLoading'))
512
+ return
513
+ }
514
+ if (
515
+ !source ||
516
+ !modelOptions.some((option) => option.endpointId === source.endpointId && option.modelId === source.modelId)
517
+ ) {
518
+ notify.toast('error', t('chat.sourceModelUnavailable'))
519
+ return
520
+ }
521
+ }
522
+ follow = true
523
+ await session.retry(consumeBase(), turn.id)
524
+ }
525
+ async function switchModel(turn: ChatTurn, model: ModelRef) {
526
+ if (!session || actionBusy) return
527
+ follow = true
528
+ if (!(await session.retry(consumeBase(), turn.id, model)))
529
+ notify.toast('error', t(`errors.${session.actionError ?? 'internal'}`))
530
+ }
531
+ async function submitReply() {
532
+ const turn = sourceReply
533
+ if (disposed) return
534
+ if (!session || !turn || actionBusy) return
535
+ let generation = viewGeneration
536
+ replyError = null
537
+ if (replyDialog === 'edit') {
538
+ const id = session.conversationId
539
+ if (!history || !id || !editDraft.trim() || editDraft.length > 24_000 || !canEdit(turn)) return
540
+ historyBusy = true
541
+ try {
542
+ const conversation = await history.editTurn(id, turn.id, editDraft, expectedText)
543
+ if (disposed || generation !== viewGeneration) return
544
+ showConversation(conversation)
545
+ generation = viewGeneration
546
+ replyOpen = false
547
+ } catch (error) {
548
+ if (disposed || generation !== viewGeneration) return
549
+ replyError = errorText(error)
550
+ } finally {
551
+ if (!disposed && generation === viewGeneration) historyBusy = false
552
+ }
553
+ } else {
554
+ if (!validReplyModel || !replyModel) return
555
+ if (!targetLanguage.trim() || targetLanguage.trim().length > 80) return
556
+ const accepted = await session.translate({ ...consumeBase(), model: replyModel }, turn.id, targetLanguage.trim())
557
+ if (disposed || generation !== viewGeneration) return
558
+ if (accepted) replyOpen = false
559
+ else replyError = session.actionError ? t(`errors.${session.actionError}`) : t('errors.internal')
560
+ }
561
+ }
562
+
563
+ let scroller = $state<HTMLDivElement | null>(null)
564
+ let follow = $state(true)
565
+ let lastScrollTop = 0
566
+ function onscroll() {
567
+ if (!scroller) return
568
+ const nearBottom = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 48
569
+ follow = nearBottom && scroller.scrollTop >= lastScrollTop
570
+ lastScrollTop = scroller.scrollTop
571
+ }
572
+ function toBottom() {
573
+ follow = true
574
+ if (scroller) {
575
+ scroller.scrollTop = scroller.scrollHeight
576
+ lastScrollTop = scroller.scrollTop
577
+ }
578
+ }
579
+ $effect.pre(() => {
580
+ if (!session || !scroller) return
581
+ for (const turn of session.turns) {
582
+ void turn.text
583
+ void turn.reasoningText
584
+ void turn.status
585
+ void turn.error
586
+ void turn.usage
587
+ void turn.search
588
+ for (const tool of turn.tools) {
589
+ void tool.pending
590
+ void tool.summary
591
+ void tool.outcome
592
+ }
593
+ }
594
+ void expanded
595
+ if (untrack(() => follow))
596
+ void tick().then(() => {
597
+ if (follow) toBottom()
598
+ })
599
+ })
600
+ function onkeydown(e: KeyboardEvent) {
601
+ if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return
602
+ // Popover content renders inline, so Escape inside it bubbles here; let the layer close itself.
603
+ if (
604
+ reasoningOpen ||
605
+ historyOpen ||
606
+ document.querySelector('[data-popover-content], [data-slot="select-content"], [role="dialog"]')
607
+ )
608
+ return
609
+ if (onClose && !session?.running) onClose()
610
+ }
611
+ type Attached = ImagePart & { preview: string }
612
+ const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
613
+ const MAX_IMAGES = 4
614
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024
615
+ let images = $state<Attached[]>([])
616
+ let reading = $state(0)
617
+ let fileInput = $state<HTMLInputElement | null>(null)
618
+ let toolsOpen = $state(false)
619
+ const vision = $derived(capability?.canVision === true)
620
+ const DEFAULT_TOOLBAR = ['new-conversation', 'web-search']
621
+ // svelte-ignore state_referenced_locally
622
+ let pinned = $state<string[]>(toolbarProp ?? DEFAULT_TOOLBAR)
623
+ let customizing = $state(false)
624
+ /** Tools usable here; unavailable pinned ids stay stored but hidden (docs/38 §3.2). */
625
+ const tools = $derived(
626
+ ['new-conversation', 'web-search', 'attachment', 'generate-image'].filter(
627
+ (id) =>
628
+ (id !== 'web-search' || (ports.search && purpose === 'chat')) &&
629
+ (id !== 'generate-image' || purpose === 'chat'),
630
+ ),
631
+ )
632
+ const toolOrder = $derived([
633
+ ...pinned.filter((id) => tools.includes(id)),
634
+ ...tools.filter((id) => !pinned.includes(id)),
635
+ ])
636
+ function setPinned(next: string[]) {
637
+ const all = [...next, ...pinned.filter((id) => !tools.includes(id))]
638
+ if (all.join() === pinned.join()) return
639
+ pinned = all
640
+ onToolbarChange?.(all)
641
+ }
642
+ function moveTool(id: string, to: number) {
643
+ const order = toolOrder.filter((item) => item !== id)
644
+ order.splice(Math.max(0, Math.min(to, order.length)), 0, id)
645
+ setPinned(order.filter((item) => pinned.includes(item)))
646
+ }
647
+ // Window listeners: keyed reordering moves the row node, which would drop pointer capture.
648
+ function dragTool(event: PointerEvent, id: string) {
649
+ const list = (event.currentTarget as HTMLElement).closest('ul')
650
+ if (!list) return
651
+ event.preventDefault()
652
+ const move = (e: PointerEvent) => {
653
+ const over = [...list.children].findIndex((row) => {
654
+ const box = row.getBoundingClientRect()
655
+ return e.clientY >= box.top && e.clientY < box.bottom
656
+ })
657
+ if (over >= 0 && toolOrder[over] !== id) moveTool(id, over)
658
+ }
659
+ const stop = () => {
660
+ window.removeEventListener('pointermove', move)
661
+ window.removeEventListener('pointerup', stop)
662
+ window.removeEventListener('pointercancel', stop)
663
+ }
664
+ window.addEventListener('pointermove', move)
665
+ window.addEventListener('pointerup', stop)
666
+ window.addEventListener('pointercancel', stop)
667
+ }
668
+ const TOOL_LABEL: Record<string, string> = {
669
+ 'new-conversation': 'chat.newConversation',
670
+ 'web-search': 'chat.network',
671
+ attachment: 'chat.uploadAttachment',
672
+ 'generate-image': 'chat.generateImage',
673
+ }
674
+ const canSend = $derived(
675
+ session !== null &&
676
+ !session.running &&
677
+ restored &&
678
+ !restoreBlocked &&
679
+ !historyBusy &&
680
+ capability !== null &&
681
+ modelError === null &&
682
+ draft.trim() !== '' &&
683
+ (images.length === 0 || vision) &&
684
+ reading === 0,
685
+ )
686
+ let recovery = $state<{
687
+ draft: string
688
+ images: Attached[]
689
+ droppedKey: string | null
690
+ contextKey: string | null
691
+ } | null>(null)
692
+ function discardRecovery() {
693
+ for (const image of recovery?.images ?? []) URL.revokeObjectURL(image.preview)
694
+ recovery = null
695
+ }
696
+ function recoverDraft() {
697
+ if (!recovery || draft !== '' || images.length > 0) return
698
+ draft = recovery.draft
699
+ images = recovery.images
700
+ draftRevision++
701
+ if (contextKey === recovery.contextKey) droppedKey = recovery.droppedKey
702
+ contextRevision++
703
+ recovery = null
704
+ }
705
+ async function send() {
706
+ if (!session || !canSend) return
707
+ const generation = viewGeneration
708
+ const original = draft
709
+ const attached = images
710
+ const revision = draftRevision
711
+ const contextVersion = contextRevision
712
+ const removed = droppedKey
713
+ const key = contextKey
714
+ draft = ''
715
+ images = []
716
+ discardRecovery()
717
+ follow = true
718
+ const accepted = await session.send(
719
+ consumeBase(),
720
+ original.trim(),
721
+ webSearch,
722
+ attached.map(({ mime, data }) => ({ mime, data })),
723
+ )
724
+ if (disposed || generation !== viewGeneration) {
725
+ for (const image of attached) URL.revokeObjectURL(image.preview)
726
+ return
727
+ }
728
+ if (accepted) {
729
+ for (const image of attached) URL.revokeObjectURL(image.preview)
730
+ if (contextRevision === contextVersion && contextKey === key) droppedKey = null
731
+ } else if (draftRevision === revision && images.length === 0) {
732
+ draft = original
733
+ images = attached
734
+ if (contextRevision === contextVersion && contextKey === key) droppedKey = removed
735
+ } else {
736
+ recovery = { draft: original, images: attached, droppedKey: removed, contextKey: key }
737
+ }
738
+ }
739
+
740
+ onDestroy(() => {
741
+ for (const image of images) URL.revokeObjectURL(image.preview)
742
+ discardRecovery()
743
+ })
744
+ function removeImage(index: number) {
745
+ const [image] = images.splice(index, 1)
746
+ if (image) URL.revokeObjectURL(image.preview)
747
+ }
748
+ function readBase64(file: File) {
749
+ const { promise, resolve, reject } = Promise.withResolvers<string>()
750
+ const reader = new FileReader()
751
+ reader.onload = () => resolve(String(reader.result).slice(String(reader.result).indexOf(',') + 1))
752
+ reader.onerror = () => reject(reader.error ?? new Error('read failed'))
753
+ reader.readAsDataURL(file)
754
+ return promise
755
+ }
756
+ /** Adds the image files to the tray, or rejects all of them with a toast. */
757
+ async function attach(files: FileList | null | undefined) {
758
+ const picked = [...(files ?? [])].filter((file) => file.type.startsWith('image/'))
759
+ if (picked.length === 0) return
760
+ if (!vision) notify.toast('error', t('chat.visionUnsupported'))
761
+ else if (picked.some((file) => !IMAGE_MIMES.includes(file.type))) notify.toast('error', t('chat.imageType'))
762
+ else if (picked.some((file) => file.size > MAX_IMAGE_BYTES)) notify.toast('error', t('chat.imageSize'))
763
+ else if (images.length + picked.length > MAX_IMAGES)
764
+ notify.toast('error', t('chat.imageLimit', { max: MAX_IMAGES }))
765
+ else {
766
+ reading++
767
+ try {
768
+ const read = await Promise.all(
769
+ picked.map(async (file) => ({ mime: file.type, data: await readBase64(file), file })),
770
+ )
771
+ // Another add may have landed while reading.
772
+ if (images.length + read.length > MAX_IMAGES) notify.toast('error', t('chat.imageLimit', { max: MAX_IMAGES }))
773
+ else images.push(...read.map(({ mime, data, file }) => ({ mime, data, preview: URL.createObjectURL(file) })))
774
+ } catch {
775
+ notify.toast('error', t('errors.internal'))
776
+ } finally {
777
+ reading--
778
+ }
779
+ }
780
+ }
781
+ function hasImages(files: FileList | null | undefined) {
782
+ return [...(files ?? [])].some((file) => file.type.startsWith('image/'))
783
+ }
784
+ </script>
785
+
786
+ {#if session}
787
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions (Esc bubbles from controls) -->
788
+ <section class="chat-panel" aria-label={t('chat.title')} {onkeydown}>
789
+ <header class="chat-header">
790
+ <div class="title-row">
791
+ <h2 {title}>{title}</h2>
792
+ {#if history}
793
+ <Button
794
+ variant="ghost"
795
+ size="icon-sm"
796
+ disabled={session.running || historyBusy}
797
+ aria-label={t('chat.history')}
798
+ title={t('chat.history')}
799
+ onclick={() => {
800
+ historyOpen = true
801
+ void refresh()
802
+ }}><HistoryIcon /></Button
803
+ >
804
+ {/if}
805
+ {#if onClose}<Button
806
+ variant="ghost"
807
+ size="icon-sm"
808
+ aria-label={t('chat.close')}
809
+ title={t('chat.close')}
810
+ onclick={onClose}><XIcon /></Button
811
+ >{/if}
812
+ </div>
813
+ <div class="model-row">
814
+ <ModelSelect
815
+ value={picked}
816
+ options={modelOptions}
817
+ filter={{ modelType: 'chat' }}
818
+ disabled={session.running || modelError !== null}
819
+ onchange={pickModel}
820
+ {...onOpenSettings ? { onOpenSettings } : {}}
821
+ />
822
+ </div>
823
+ </header>
824
+ {#if historyError && !historyOpen}
825
+ <Alert.Root variant="destructive">
826
+ <Alert.Title>{t('chat.historyFailed')}</Alert.Title>
827
+ <Alert.Description
828
+ >{historyError}<Button
829
+ variant="outline"
830
+ size="sm"
831
+ disabled={historyBusy || session.running}
832
+ onclick={() => void retryHistory()}>{t('chat.retryHistory')}</Button
833
+ ></Alert.Description
834
+ >
835
+ </Alert.Root>
836
+ {/if}
837
+ {#if session.actionError}<p role="alert" class="text-destructive text-kit-sm">
838
+ {t(`errors.${session.actionError}`)}
839
+ </p>{/if}
840
+ {#if session.historyRefreshError}<p role="alert" class="text-destructive text-kit-sm">
841
+ {t('chat.replyRefreshFailed')}<Button
842
+ variant="outline"
843
+ size="sm"
844
+ disabled={actionBusy}
845
+ onclick={() => session.conversationId && void open(session.conversationId)}>{t('chat.retryHistory')}</Button
846
+ >
847
+ </p>{/if}
848
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex (native scroll region needs keyboard focus for PageUp/PageDown) -->
849
+ <div class="messages" bind:this={scroller} {onscroll} role="region" aria-label={t('chat.messages')} tabindex="0">
850
+ {#if session.turns.length > 0}
851
+ <ol class="turns">
852
+ {#each groups as [questionId, replies] (questionId)}
853
+ {@const candidates = replies.filter((reply) => reply.kind === 'reply').length > 1}
854
+ {#each replies as turn, i (turn.id)}
855
+ <MessageItem
856
+ {turn}
857
+ {onInsert}
858
+ {onSaveNote}
859
+ {onExportMarkdown}
860
+ modelLabel={turn.kind === 'translation'
861
+ ? `${t('chat.translationReply')} · ${sourceLabel(turn)}`
862
+ : sourceLabel(turn)}
863
+ showInput={i === 0 && turn.kind !== 'translation'}
864
+ chosen={candidates && session.selectedTurnId === turn.id}
865
+ busy={actionBusy}
866
+ editable={canEdit(turn)}
867
+ onRetry={purpose === 'chat' && !restoreBlocked && (turn.persisted || capability !== null)
868
+ ? () => void retryReply(turn)
869
+ : undefined}
870
+ onEdit={purpose === 'chat' && history && session.conversationId && turn.persisted
871
+ ? () => editReply(turn)
872
+ : undefined}
873
+ onBranch={purpose === 'chat' && history && session.conversationId && turn.persisted
874
+ ? () => void mutateReply(turn, 'branch')
875
+ : undefined}
876
+ onCopyConversation={purpose === 'chat' && history && session.conversationId && turn.persisted
877
+ ? () => void mutateReply(turn, 'copy')
878
+ : undefined}
879
+ onSwitchModel={purpose === 'chat' && history && session.conversationId && turn.persisted
880
+ ? (picked: ModelRef) => void switchModel(turn, picked)
881
+ : undefined}
882
+ modelOptions={turn.images.length > 0
883
+ ? (modelOptions?.filter((option) => option.canVision) ?? null)
884
+ : modelOptions}
885
+ sourceModel={sourceModel(turn)}
886
+ onTranslate={purpose === 'chat' && history && session.conversationId && turn.persisted
887
+ ? () => translateDialog(turn)
888
+ : undefined}
889
+ onDelete={purpose === 'chat' && history && session.conversationId && turn.persisted
890
+ ? () => void mutateReply(turn, 'remove')
891
+ : undefined}
892
+ onUseContext={candidates && purpose === 'chat' && history && session.conversationId && turn.persisted
893
+ ? () => void mutateReply(turn, 'select')
894
+ : undefined}
895
+ onApprove={(tool, allow) => session.approve(tool, allow)}
896
+ />
897
+ {/each}
898
+ {/each}
899
+ </ol>
900
+ {/if}
901
+ {#if modelError}
902
+ <Alert.Root variant="destructive">
903
+ <Alert.Title>{t('chat.modelsFailed')}</Alert.Title>
904
+ <Alert.Description
905
+ >{modelError}<Button variant="outline" size="sm" onclick={() => modelReload++}
906
+ >{t('chat.retryModels')}</Button
907
+ ></Alert.Description
908
+ >
909
+ </Alert.Root>
910
+ {:else if modelOptions === null}
911
+ <p class="empty-message" role="status">{t('chat.modelsLoading')}</p>
912
+ {:else if noModels}
913
+ <Empty.Root
914
+ ><Empty.Description>{t('chat.noModels')}</Empty.Description>{#if onOpenSettings}<Button
915
+ variant="outline"
916
+ size="sm"
917
+ onclick={onOpenSettings}>{t('chat.openSettings')}</Button
918
+ >{/if}</Empty.Root
919
+ >
920
+ {:else if session.turns.length === 0 && !restoreBlocked}
921
+ <p class="empty-message">{t('chat.empty')}</p>
922
+ {/if}
923
+ </div>
924
+ {#if !follow && session.turns.length > 0}
925
+ <div class="bottom-control">
926
+ <Button type="button" variant="secondary" size="sm" onclick={toBottom}
927
+ ><ArrowDownIcon data-icon="inline-start" />{t('chat.backToBottom')}</Button
928
+ >
929
+ </div>
930
+ {/if}
931
+ <form
932
+ class="composer"
933
+ class:expanded
934
+ onsubmit={(e) => {
935
+ e.preventDefault()
936
+ void send()
937
+ }}
938
+ ondragover={(e) => {
939
+ if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
940
+ }}
941
+ ondrop={(e) => {
942
+ if (!hasImages(e.dataTransfer?.files)) return
943
+ e.preventDefault()
944
+ void attach(e.dataTransfer?.files)
945
+ }}
946
+ >
947
+ <div class="expand-control">
948
+ <Button
949
+ type="button"
950
+ variant="ghost"
951
+ size="icon-sm"
952
+ aria-label={t(expanded ? 'chat.collapseInput' : 'chat.expandInput')}
953
+ title={t(expanded ? 'chat.collapseInput' : 'chat.expandInput')}
954
+ aria-expanded={expanded}
955
+ onmousedown={(e) => e.preventDefault()}
956
+ onclick={() => {
957
+ expanded = !expanded
958
+ }}
959
+ >
960
+ {#if expanded}<MinimizeIcon />{:else}<MaximizeIcon />{/if}
961
+ </Button>
962
+ </div>
963
+ {#if activeContext}
964
+ <div class="context-chip">
965
+ <span title={activeContext.label}>{activeContext.label}</span><Button
966
+ type="button"
967
+ variant="ghost"
968
+ size="icon-sm"
969
+ aria-label={t('chat.context.remove', { label: activeContext.label })}
970
+ title={t('chat.context.remove', { label: activeContext.label })}
971
+ onclick={() => {
972
+ droppedKey = contextKey
973
+ contextRevision++
974
+ }}><XIcon /></Button
975
+ >
976
+ </div>
977
+ {/if}
978
+ {#if images.length > 0}
979
+ <ul class="image-tray">
980
+ {#each images as image, index (image.preview)}
981
+ <li>
982
+ <img src={image.preview} alt="" /><Button
983
+ type="button"
984
+ variant="secondary"
985
+ size="icon-xs"
986
+ aria-label={t('chat.removeImage', { index: index + 1 })}
987
+ title={t('chat.removeImage', { index: index + 1 })}
988
+ onclick={() => removeImage(index)}><XIcon /></Button
989
+ >
990
+ </li>
991
+ {/each}
992
+ </ul>
993
+ {#if !vision}<p class="search-status" role="status">{t('chat.visionUnsupported')}</p>{/if}
994
+ {/if}
995
+ <Textarea
996
+ bind:value={draft}
997
+ rows={3}
998
+ aria-label={t('chat.input')}
999
+ placeholder={t('chat.placeholder')}
1000
+ oninput={() => draftRevision++}
1001
+ onpaste={(e) => {
1002
+ const files = e.clipboardData?.files
1003
+ if (!hasImages(files)) return
1004
+ e.preventDefault()
1005
+ void attach(files)
1006
+ }}
1007
+ onkeydown={(e) => {
1008
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.isComposing && e.keyCode !== 229) {
1009
+ e.preventDefault()
1010
+ if (!e.repeat) void send()
1011
+ }
1012
+ }}
1013
+ />
1014
+ {#if recovery}
1015
+ <div class="draft-recovery" role="status">
1016
+ <span>{t('chat.draftRecoverable')}</span><Button
1017
+ type="button"
1018
+ variant="outline"
1019
+ size="sm"
1020
+ disabled={draft !== '' || images.length > 0}
1021
+ onclick={recoverDraft}>{t('chat.restoreDraft')}</Button
1022
+ >
1023
+ </div>
1024
+ {/if}
1025
+ {#if ports.search && purpose === 'chat' && session.search.status === 'searching'}<p
1026
+ class="search-status"
1027
+ role="status"
1028
+ >
1029
+ {t('chat.searching')}
1030
+ </p>{/if}
1031
+ <div class="composer-tools">
1032
+ <div class="tool-group">
1033
+ {#snippet toolIcon(id: string)}
1034
+ {#if id === 'new-conversation'}<MessageSquarePlusIcon />{:else if id === 'web-search'}<GlobeIcon
1035
+ >{#if !webSearch}<path d="m2 2 20 20" />{/if}</GlobeIcon
1036
+ >{:else if id === 'attachment'}<PaperclipIcon />{:else}<ImageIcon />{/if}
1037
+ {/snippet}
1038
+ {#each pinned.filter((id) => tools.includes(id)) as id (id)}
1039
+ {#if id === 'new-conversation'}<Button
1040
+ type="button"
1041
+ variant="ghost"
1042
+ size="icon-sm"
1043
+ disabled={session.running || historyBusy}
1044
+ aria-label={t('chat.newConversation')}
1045
+ title={t('chat.newConversation')}
1046
+ onclick={newConversation}>{@render toolIcon(id)}</Button
1047
+ >{:else if id === 'web-search'}<Button
1048
+ type="button"
1049
+ variant="ghost"
1050
+ class="search-toggle"
1051
+ size="icon-sm"
1052
+ aria-label={t('chat.network')}
1053
+ aria-pressed={webSearch}
1054
+ disabled={session.running}
1055
+ title={`${t(webSearch ? 'chat.networkOn' : 'chat.networkOff')} · ${t('chat.networkPrivacy')}`}
1056
+ onclick={() => (webSearch = !webSearch)}>{@render toolIcon(id)}</Button
1057
+ >{:else if id === 'attachment'}<Button
1058
+ type="button"
1059
+ variant="ghost"
1060
+ size="icon-sm"
1061
+ aria-label={t('chat.uploadAttachment')}
1062
+ disabled={session.running || !vision}
1063
+ title={vision ? t('chat.uploadAttachment') : t('chat.visionUnsupported')}
1064
+ onclick={() => fileInput?.click()}>{@render toolIcon(id)}</Button
1065
+ >{:else}<Button
1066
+ type="button"
1067
+ variant="ghost"
1068
+ class="search-toggle"
1069
+ size="icon-sm"
1070
+ aria-label={t('chat.generateImage')}
1071
+ aria-pressed={generateImage}
1072
+ disabled={session.running || !paintingModel || capability?.supportsTools === false}
1073
+ title={!paintingModel
1074
+ ? t('chat.paintingUnconfigured')
1075
+ : capability?.supportsTools === false
1076
+ ? t('chat.toolsUnsupported')
1077
+ : t('chat.generateImage')}
1078
+ onclick={() => (generateImage = !generateImage)}>{@render toolIcon(id)}</Button
1079
+ >{/if}
1080
+ {/each}
1081
+ <input
1082
+ bind:this={fileInput}
1083
+ type="file"
1084
+ accept={IMAGE_MIMES.join(',')}
1085
+ multiple
1086
+ hidden
1087
+ onchange={(e) => {
1088
+ const input = e.currentTarget
1089
+ void attach(input.files).finally(() => (input.value = ''))
1090
+ }}
1091
+ />
1092
+ <Popover.Root bind:open={toolsOpen} onOpenChange={(open) => !open && (customizing = false)}>
1093
+ <Popover.Trigger disabled={session.running}>
1094
+ {#snippet child({ props })}<Button
1095
+ {...props}
1096
+ type="button"
1097
+ variant="ghost"
1098
+ size="icon-sm"
1099
+ aria-label={t('chat.tools')}
1100
+ title={t('chat.tools')}><PlusIcon /></Button
1101
+ >{/snippet}
1102
+ </Popover.Trigger>
1103
+ <Popover.Content class="tools-menu" side="top" align="start" sideOffset={6} collisionPadding={8}>
1104
+ {#if customizing}
1105
+ <p class="tools-title" id="{reasoningId}-toolbar">{t('chat.toolbarCustomize')}</p>
1106
+ <ul class="tools-order" aria-labelledby="{reasoningId}-toolbar">
1107
+ {#each toolOrder as id, index (id)}
1108
+ <li class="tools-row">
1109
+ <button
1110
+ type="button"
1111
+ class="tools-grip"
1112
+ aria-label={t('chat.toolbarDrag', { name: t(TOOL_LABEL[id] ?? id) })}
1113
+ onpointerdown={(e) => dragTool(e, id)}
1114
+ onkeydown={(e) => {
1115
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
1116
+ e.preventDefault()
1117
+ const button = e.currentTarget
1118
+ moveTool(id, index + (e.key === 'ArrowUp' ? -1 : 1))
1119
+ void tick().then(() => button.focus())
1120
+ }}><GripVerticalIcon /></button
1121
+ >
1122
+ {@render toolIcon(id)}<span>{t(TOOL_LABEL[id] ?? id)}</span>
1123
+ <Switch
1124
+ size="sm"
1125
+ aria-label={t(TOOL_LABEL[id] ?? id)}
1126
+ checked={pinned.includes(id)}
1127
+ onCheckedChange={(on) =>
1128
+ setPinned(toolOrder.filter((item) => (item === id ? on : pinned.includes(item))))}
1129
+ />
1130
+ </li>
1131
+ {/each}
1132
+ </ul>
1133
+ <button
1134
+ type="button"
1135
+ class="tools-item tools-footer"
1136
+ disabled={pinned.join() === DEFAULT_TOOLBAR.join()}
1137
+ onclick={() => {
1138
+ pinned = [...DEFAULT_TOOLBAR]
1139
+ onToolbarChange?.(pinned)
1140
+ }}><RotateCcwIcon /><span>{t('chat.toolbarRestore')}</span></button
1141
+ >
1142
+ {:else}
1143
+ {#each toolOrder.filter((id) => !pinned.includes(id)) as id (id)}
1144
+ {#if id === 'new-conversation'}<button
1145
+ type="button"
1146
+ class="tools-item"
1147
+ disabled={historyBusy}
1148
+ onclick={() => {
1149
+ toolsOpen = false
1150
+ newConversation()
1151
+ }}>{@render toolIcon(id)}<span>{t('chat.newConversation')}</span></button
1152
+ >{:else if id === 'web-search'}<button
1153
+ type="button"
1154
+ class="tools-item"
1155
+ aria-pressed={webSearch}
1156
+ onclick={() => {
1157
+ toolsOpen = false
1158
+ webSearch = !webSearch
1159
+ }}
1160
+ >{@render toolIcon(id)}<span>{t('chat.network')}</span>{#if webSearch}<CheckIcon />{/if}</button
1161
+ >{:else if id === 'attachment'}<button
1162
+ type="button"
1163
+ class="tools-item"
1164
+ disabled={!vision}
1165
+ title={vision ? undefined : t('chat.visionUnsupported')}
1166
+ onclick={() => {
1167
+ toolsOpen = false
1168
+ fileInput?.click()
1169
+ }}
1170
+ >{@render toolIcon(id)}<span>{t('chat.uploadAttachment')}</span>{#if !vision}<small
1171
+ >{t('chat.visionUnsupported')}</small
1172
+ >{/if}</button
1173
+ >{:else}<button
1174
+ type="button"
1175
+ class="tools-item"
1176
+ aria-pressed={generateImage}
1177
+ disabled={!paintingModel || capability?.supportsTools === false}
1178
+ title={!paintingModel
1179
+ ? t('chat.paintingUnconfigured')
1180
+ : capability?.supportsTools === false
1181
+ ? t('chat.toolsUnsupported')
1182
+ : undefined}
1183
+ onclick={() => {
1184
+ toolsOpen = false
1185
+ generateImage = !generateImage
1186
+ }}
1187
+ >{@render toolIcon(id)}<span>{t('chat.generateImage')}</span>{#if !paintingModel}<small
1188
+ >{t('chat.paintingUnconfigured')}</small
1189
+ >{:else if generateImage}<CheckIcon />{/if}</button
1190
+ >{/if}
1191
+ {/each}
1192
+ <button
1193
+ type="button"
1194
+ class="tools-item tools-footer"
1195
+ aria-label={t('chat.toolbarCustomize')}
1196
+ onclick={() => (customizing = true)}><span>{t('chat.quickPanel')}</span><Settings2Icon /></button
1197
+ >
1198
+ {/if}
1199
+ </Popover.Content>
1200
+ </Popover.Root>
1201
+ {#if generateImage && paintingModel && purpose === 'chat' && !pinned.includes('generate-image')}<Button
1202
+ type="button"
1203
+ variant="secondary"
1204
+ size="sm"
1205
+ class="tool-chip"
1206
+ aria-label={t('chat.generateImageOff')}
1207
+ title={t('chat.generateImageOff')}
1208
+ onclick={() => (generateImage = false)}><ImageIcon />{t('chat.generateImage')}<XIcon /></Button
1209
+ >{/if}
1210
+ {#if ports.skills}<div class="skills-control">
1211
+ <SkillPicker
1212
+ port={ports.skills}
1213
+ value={selected}
1214
+ budget={skillsBudget}
1215
+ changed={session.skillChanged}
1216
+ onchange={setSkills}
1217
+ onresolved={() => (session.skillChanged = null)}
1218
+ />
1219
+ </div>{/if}
1220
+ </div>
1221
+ <div class="tool-group send-tools">
1222
+ {#if mode === 'plan'}<span class="plan-badge" role="status">{t('chat.mode.planBadge')}</span>{/if}
1223
+ {#if allowedModes.length > 1 || allowedModes.includes('full_access')}
1224
+ <DropdownMenu.Root>
1225
+ <DropdownMenu.Trigger disabled={session.running}>
1226
+ {#snippet child({ props })}<Button
1227
+ {...props}
1228
+ variant="ghost"
1229
+ size="sm"
1230
+ class={mode === 'full_access' ? 'mode-danger' : undefined}
1231
+ aria-label={`${t('chat.mode.label')}: ${t(`chat.mode.${mode}`)}`}
1232
+ ><ShieldIcon data-icon="inline-start" />{t(`chat.mode.${mode}`)}<ChevronDownIcon
1233
+ data-icon="inline-end"
1234
+ /></Button
1235
+ >{/snippet}
1236
+ </DropdownMenu.Trigger>
1237
+ <DropdownMenu.Content side="top" align="end" class="mode-menu">
1238
+ <DropdownMenu.Group aria-label={t('chat.mode.label')}>
1239
+ {#each allowedModes as option (option)}
1240
+ <DropdownMenu.Item
1241
+ class={option === 'full_access' ? 'mode-option mode-danger' : 'mode-option'}
1242
+ aria-current={option === mode ? 'true' : undefined}
1243
+ onSelect={() => void chooseMode(option)}
1244
+ >{#if option === 'default'}<HandIcon />
1245
+ {:else if option === 'plan'}<RouteIcon />
1246
+ {:else if option === 'accept_edits'}<FilePenIcon />
1247
+ {:else if option === 'auto'}<ShieldCheckIcon />
1248
+ {:else}<ShieldAlertIcon />{/if}
1249
+ <span class="mode-name">{t(`chat.mode.${option}`)}</span>
1250
+ <span class="mode-description">{t(`chat.mode.${option}Hint`)}</span>
1251
+ <span class="mode-notice">
1252
+ {#if option === 'auto' || option === 'full_access'}
1253
+ <span title={t(option === 'auto' ? 'chat.mode.ruleNotice' : 'chat.mode.fullAccessBody')}>
1254
+ <InfoIcon />
1255
+ </span>
1256
+ {/if}
1257
+ </span>
1258
+ <span class="mode-check"
1259
+ >{#if option === mode}<CheckIcon />{/if}</span
1260
+ ></DropdownMenu.Item
1261
+ >
1262
+ {/each}
1263
+ </DropdownMenu.Group>
1264
+ <div class="mode-footer">
1265
+ <span>{t('chat.mode.label')}</span>
1266
+ <span class="mode-keys"
1267
+ ><kbd>↑↓</kbd>{t('chat.mode.navigate')}<kbd>↵</kbd>{t('chat.mode.confirm')}<kbd>Esc</kbd>{t(
1268
+ 'chat.mode.close',
1269
+ )}</span
1270
+ >
1271
+ </div>
1272
+ </DropdownMenu.Content>
1273
+ </DropdownMenu.Root>
1274
+ {/if}
1275
+ {#if picked}
1276
+ <Popover.Root bind:open={reasoningOpen}>
1277
+ <Popover.Trigger disabled={session.running}>
1278
+ {#snippet child({ props })}
1279
+ <Button
1280
+ {...props}
1281
+ bind:ref={reasoningTrigger}
1282
+ variant="ghost"
1283
+ size="sm"
1284
+ aria-label={`${t('chat.reasoningEffort')}: ${effortLabel}`}
1285
+ >
1286
+ <GaugeIcon data-icon="inline-start" />
1287
+ {effortLabel}
1288
+ <ChevronDownIcon data-icon="inline-end" />
1289
+ </Button>
1290
+ {/snippet}
1291
+ </Popover.Trigger>
1292
+ <Popover.Content
1293
+ class="reasoning-popover"
1294
+ side="top"
1295
+ align="end"
1296
+ sideOffset={8}
1297
+ collisionPadding={8}
1298
+ trapFocus={false}
1299
+ preventScroll={false}
1300
+ aria-label={t('chat.reasoningEffort')}
1301
+ onEscapeKeydown={(event) => {
1302
+ if (event.isComposing) event.preventDefault()
1303
+ event.stopPropagation()
1304
+ }}
1305
+ onCloseAutoFocus={(event) => {
1306
+ event.preventDefault()
1307
+ reasoningTrigger?.focus({ preventScroll: true })
1308
+ }}
1309
+ >
1310
+ <div class="reasoning-heading">
1311
+ <p>{t('chat.reasoningStrength')}: <strong>{effortLabel}</strong></p>
1312
+ <Button
1313
+ variant="secondary"
1314
+ size="xs"
1315
+ disabled={session.running || chosenEffort === 'default'}
1316
+ onclick={() => chooseEffort('default')}>{t('chat.resetReasoning')}</Button
1317
+ >
1318
+ </div>
1319
+ {#if allowedEfforts.length === 0}
1320
+ <p class="reasoning-hint">{t('chat.reasoningUnconfigured')}</p>
1321
+ {#if onOpenSettings}<Button
1322
+ variant="outline"
1323
+ size="sm"
1324
+ onclick={() => {
1325
+ reasoningOpen = false
1326
+ settingsTarget.value = picked
1327
+ ? { endpointId: picked.endpointId, modelId: picked.modelId }
1328
+ : null
1329
+ onOpenSettings()
1330
+ }}>{t('chat.openSettings')}</Button
1331
+ >{/if}
1332
+ {:else if allowedEfforts.length > 1}
1333
+ <div class="reasoning-scale" aria-hidden="true">
1334
+ <span>{t('chat.reasoningFaster')}</span><span>{t('chat.reasoningSmarter')}</span>
1335
+ </div>
1336
+ <div
1337
+ class="reasoning-slider"
1338
+ style:--reasoning-progress={`${(effortIndex / (allowedEfforts.length - 1)) * 100}%`}
1339
+ >
1340
+ <input
1341
+ type="range"
1342
+ min="0"
1343
+ max={allowedEfforts.length - 1}
1344
+ step="1"
1345
+ value={effortIndex}
1346
+ aria-label={t('chat.reasoningEffort')}
1347
+ aria-valuetext={effortLabel}
1348
+ disabled={session.running}
1349
+ oninput={commitReasoning}
1350
+ onpointerup={(event) => {
1351
+ if (event.button === 0) commitReasoning(event)
1352
+ }}
1353
+ onkeyup={(event) => {
1354
+ if (
1355
+ !event.isComposing &&
1356
+ ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)
1357
+ ) {
1358
+ commitReasoning(event)
1359
+ }
1360
+ }}
1361
+ />
1362
+ <div class="reasoning-ticks" aria-hidden="true">
1363
+ {#each allowedEfforts as effort, index (effort)}
1364
+ <span
1365
+ class="reasoning-tick"
1366
+ data-effort={effort}
1367
+ class:current={index === effortIndex}
1368
+ style:left={`${(index / (allowedEfforts.length - 1)) * 100}%`}
1369
+ ></span>
1370
+ {/each}
1371
+ </div>
1372
+ </div>
1373
+ {:else}
1374
+ <Field.FieldSet disabled={session.running}>
1375
+ <Field.FieldLegend class="sr-only">{t('chat.reasoningEffort')}</Field.FieldLegend>
1376
+ {#each ['default', ...allowedEfforts] as effort (effort)}
1377
+ <label class="reasoning-option">
1378
+ <input
1379
+ type="radio"
1380
+ name={reasoningId}
1381
+ value={effort}
1382
+ checked={reasoningEffort === effort}
1383
+ onchange={() => chooseEffort(effort as ReasoningEffort)}
1384
+ />
1385
+ {t(`chat.effort.${effort}`)}
1386
+ </label>
1387
+ {/each}
1388
+ </Field.FieldSet>
1389
+ {/if}
1390
+ {#if reasoningEffort === 'default'}<p class="reasoning-hint">{t('chat.reasoningDefaultHint')}</p>{/if}
1391
+ </Popover.Content>
1392
+ </Popover.Root>
1393
+ {/if}
1394
+ <UsageDetails
1395
+ context={currentContext}
1396
+ usage={session.turns.at(-1)?.usage ?? null}
1397
+ total={session.cumulativeUsage}
1398
+ cost={session.turns.at(-1)?.cost ?? null}
1399
+ totalCost={session.cumulativeCost}
1400
+ />
1401
+ {#if session.running}<Button
1402
+ type="button"
1403
+ variant="destructive"
1404
+ size="icon-sm"
1405
+ aria-label={t('chat.stop')}
1406
+ title={t('chat.stop')}
1407
+ onclick={() => session.stop()}><SquareIcon /></Button
1408
+ >
1409
+ {:else}<Button
1410
+ type="submit"
1411
+ size="icon-sm"
1412
+ disabled={!canSend}
1413
+ aria-label={t('chat.send')}
1414
+ title={t('chat.send')}><ArrowUpIcon /></Button
1415
+ >{/if}
1416
+ </div>
1417
+ </div>
1418
+ </form>
1419
+ {#if history}
1420
+ <Dialog.Root bind:open={historyOpen}>
1421
+ <Dialog.Content showCloseButton={false}>
1422
+ <Dialog.Header><Dialog.Title>{t('chat.history')}</Dialog.Title></Dialog.Header>
1423
+ {#if historyLoading}<p role="status">{t('chat.historyLoading')}</p>{/if}
1424
+ {#if historyError}<p role="alert">
1425
+ {t('chat.historyFailed')}: {historyError}<Button
1426
+ variant="outline"
1427
+ size="sm"
1428
+ disabled={historyBusy}
1429
+ onclick={() => void retryHistory()}>{t('chat.retryHistory')}</Button
1430
+ >
1431
+ </p>{/if}
1432
+ <div class="history-list">
1433
+ {#each conversations as c (c.id)}<Button
1434
+ variant="ghost"
1435
+ title={c.title}
1436
+ disabled={historyBusy || session.running}
1437
+ onclick={() => void open(c.id)}><span>{c.title}</span></Button
1438
+ >{/each}
1439
+ </div>
1440
+ {#if session.conversationId}
1441
+ {#if renaming}
1442
+ <form
1443
+ onsubmit={(e) => {
1444
+ e.preventDefault()
1445
+ void rename()
1446
+ }}
1447
+ >
1448
+ <Field.FieldGroup
1449
+ ><Field.Field
1450
+ ><Field.FieldLabel for="chat-conversation-title">{t('chat.conversationTitle')}</Field.FieldLabel
1451
+ ><Input id="chat-conversation-title" bind:value={renameTitle} disabled={historyBusy} /></Field.Field
1452
+ ><Button type="submit" disabled={historyBusy || renameTitle.trim() === ''}
1453
+ >{t('common.confirm')}</Button
1454
+ ></Field.FieldGroup
1455
+ >
1456
+ </form>
1457
+ {:else}<Button
1458
+ variant="outline"
1459
+ disabled={historyBusy || session.running}
1460
+ onclick={() => {
1461
+ renameTitle = title
1462
+ renaming = true
1463
+ }}>{t('chat.rename')}</Button
1464
+ >{/if}
1465
+ <Button variant="destructive" disabled={historyBusy || session.running} onclick={() => void remove()}
1466
+ >{t('chat.delete')}</Button
1467
+ >
1468
+ {/if}
1469
+ <Dialog.Footer
1470
+ ><Button variant="ghost" onclick={() => (historyOpen = false)}>{t('chat.close')}</Button></Dialog.Footer
1471
+ >
1472
+ </Dialog.Content>
1473
+ </Dialog.Root>
1474
+ {/if}
1475
+ <Dialog.Root bind:open={replyOpen}>
1476
+ <Dialog.Content class="reply-dialog" showCloseButton={false}>
1477
+ <Dialog.Header>
1478
+ <Dialog.Title>{t(replyDialog === 'edit' ? 'chat.editReply' : 'chat.translate')}</Dialog.Title>
1479
+ <Dialog.Description
1480
+ >{t(replyDialog === 'edit' ? 'chat.editReplyHint' : 'chat.candidateHint')}</Dialog.Description
1481
+ >
1482
+ </Dialog.Header>
1483
+ <form
1484
+ onsubmit={(event) => {
1485
+ event.preventDefault()
1486
+ void submitReply()
1487
+ }}
1488
+ >
1489
+ <Field.FieldGroup>
1490
+ {#if replyDialog === 'edit'}
1491
+ <Field.Field
1492
+ ><Field.FieldLabel for="chat-reply-edit">{t('chat.replyText')}</Field.FieldLabel><Textarea
1493
+ id="chat-reply-edit"
1494
+ bind:value={editDraft}
1495
+ rows={8}
1496
+ maxlength={24_000}
1497
+ disabled={actionBusy}
1498
+ /></Field.Field
1499
+ >
1500
+ {:else}
1501
+ <Field.Field
1502
+ ><Field.FieldLabel>{t('chat.replyModel')}</Field.FieldLabel><ModelSelect
1503
+ value={replyModel}
1504
+ options={modelOptions}
1505
+ disabled={actionBusy || modelError !== null}
1506
+ onchange={(value) => (replyModel = value)}
1507
+ /></Field.Field
1508
+ >
1509
+ {#if noModels}<p role="status">{t('chat.noModels')}</p>{/if}
1510
+ {#if modelError}<p role="alert">
1511
+ {modelError}<Button type="button" variant="outline" size="sm" onclick={() => modelReload++}
1512
+ >{t('chat.retryModels')}</Button
1513
+ >
1514
+ </p>{/if}
1515
+ <Field.Field
1516
+ ><Field.FieldLabel for="chat-target-language">{t('chat.targetLanguage')}</Field.FieldLabel><Input
1517
+ id="chat-target-language"
1518
+ bind:value={targetLanguage}
1519
+ maxlength={80}
1520
+ disabled={actionBusy}
1521
+ /></Field.Field
1522
+ >
1523
+ {/if}
1524
+ {#if replyError}<p role="alert" class="text-destructive">{replyError}</p>{/if}
1525
+ <Dialog.Footer>
1526
+ <Button type="button" variant="ghost" disabled={actionBusy} onclick={() => (replyOpen = false)}
1527
+ >{t('common.cancel')}</Button
1528
+ >
1529
+ <Button
1530
+ type="submit"
1531
+ disabled={actionBusy ||
1532
+ (replyDialog === 'edit'
1533
+ ? !editDraft.trim() || editDraft.length > 24_000
1534
+ : !validReplyModel || !targetLanguage.trim())}>{t('common.confirm')}</Button
1535
+ >
1536
+ </Dialog.Footer>
1537
+ </Field.FieldGroup>
1538
+ </form>
1539
+ </Dialog.Content>
1540
+ </Dialog.Root>
1541
+ </section>
1542
+ {/if}
1543
+
1544
+ <style>
1545
+ .chat-panel {
1546
+ display: flex;
1547
+ flex: 1;
1548
+ flex-direction: column;
1549
+ gap: 0.75rem;
1550
+ min-width: 0;
1551
+ min-height: 0;
1552
+ height: 100%;
1553
+ container-type: size;
1554
+ }
1555
+ .chat-header {
1556
+ display: flex;
1557
+ flex-direction: column;
1558
+ gap: 0.5rem;
1559
+ flex: none;
1560
+ min-width: 0;
1561
+ }
1562
+ .title-row {
1563
+ display: flex;
1564
+ align-items: center;
1565
+ gap: 0.25rem;
1566
+ min-width: 0;
1567
+ }
1568
+ .title-row h2 {
1569
+ flex: 1;
1570
+ min-width: 0;
1571
+ margin: 0;
1572
+ font: inherit;
1573
+ font-weight: 600;
1574
+ overflow: hidden;
1575
+ text-overflow: ellipsis;
1576
+ white-space: nowrap;
1577
+ }
1578
+ .title-row :global(button) {
1579
+ flex-shrink: 0;
1580
+ }
1581
+ .model-row {
1582
+ min-width: 0;
1583
+ }
1584
+ .messages {
1585
+ flex: 1;
1586
+ min-height: 0;
1587
+ min-width: 0;
1588
+ overflow-y: auto;
1589
+ overflow-x: clip;
1590
+ overscroll-behavior: contain;
1591
+ overflow-anchor: none;
1592
+ }
1593
+ .turns {
1594
+ display: flex;
1595
+ flex-direction: column;
1596
+ gap: 1rem;
1597
+ padding: 0;
1598
+ margin: 0;
1599
+ list-style: none;
1600
+ }
1601
+ .empty-message {
1602
+ color: var(--color-muted-foreground);
1603
+ text-align: center;
1604
+ padding: 1.5rem 0.5rem;
1605
+ margin: 0;
1606
+ }
1607
+ .bottom-control {
1608
+ display: flex;
1609
+ justify-content: center;
1610
+ }
1611
+ .composer {
1612
+ position: relative;
1613
+ flex: none;
1614
+ min-width: 0;
1615
+ display: flex;
1616
+ flex-direction: column;
1617
+ gap: 0.5rem;
1618
+ border: 1px solid var(--color-border);
1619
+ border-radius: calc(var(--kit-radius, var(--radius)) * 1.5);
1620
+ padding: 0.5rem;
1621
+ }
1622
+ .expand-control {
1623
+ position: absolute;
1624
+ top: 0.375rem;
1625
+ right: 0.375rem;
1626
+ }
1627
+ .composer :global(textarea) {
1628
+ min-height: 5rem;
1629
+ height: 5rem;
1630
+ max-height: 5rem;
1631
+ field-sizing: fixed;
1632
+ resize: none;
1633
+ padding-right: 2.25rem;
1634
+ border: 0;
1635
+ box-shadow: none;
1636
+ border-radius: calc(var(--kit-radius, var(--radius)) * 0.5);
1637
+ }
1638
+ .composer.expanded :global(textarea) {
1639
+ height: 40cqh;
1640
+ max-height: 40cqh;
1641
+ }
1642
+ .composer :global(textarea:focus-visible) {
1643
+ outline: 2px solid var(--color-ring);
1644
+ outline-offset: -2px;
1645
+ }
1646
+ .context-chip {
1647
+ display: flex;
1648
+ align-items: center;
1649
+ align-self: flex-start;
1650
+ gap: 0.25rem;
1651
+ max-width: calc(100% - 2rem);
1652
+ padding-left: 0.5rem;
1653
+ background: var(--color-muted);
1654
+ border-radius: var(--kit-radius, var(--radius));
1655
+ }
1656
+ .context-chip span {
1657
+ min-width: 0;
1658
+ overflow: hidden;
1659
+ text-overflow: ellipsis;
1660
+ white-space: nowrap;
1661
+ }
1662
+ .context-chip :global(button) {
1663
+ flex-shrink: 0;
1664
+ }
1665
+ .image-tray {
1666
+ display: flex;
1667
+ flex-wrap: wrap;
1668
+ gap: 0.375rem;
1669
+ margin: 0;
1670
+ padding: 0;
1671
+ list-style: none;
1672
+ }
1673
+ .image-tray li {
1674
+ position: relative;
1675
+ }
1676
+ .image-tray img {
1677
+ width: 3.5rem;
1678
+ height: 3.5rem;
1679
+ object-fit: cover;
1680
+ border: 1px solid var(--color-border);
1681
+ border-radius: var(--kit-radius, var(--radius));
1682
+ }
1683
+ .image-tray :global(button) {
1684
+ position: absolute;
1685
+ top: -0.375rem;
1686
+ right: -0.375rem;
1687
+ }
1688
+ .composer-tools,
1689
+ .tool-group {
1690
+ display: flex;
1691
+ align-items: center;
1692
+ flex-wrap: wrap;
1693
+ gap: 0.25rem;
1694
+ min-width: 0;
1695
+ }
1696
+ .composer-tools {
1697
+ justify-content: space-between;
1698
+ gap: 0.5rem;
1699
+ }
1700
+ .send-tools {
1701
+ margin-left: auto;
1702
+ }
1703
+ .chat-panel :global(button.search-toggle) {
1704
+ color: var(--color-muted-foreground);
1705
+ }
1706
+ .chat-panel :global(button.search-toggle[aria-pressed='true']) {
1707
+ color: var(--color-primary-foreground);
1708
+ background: var(--color-primary);
1709
+ border-color: var(--color-primary);
1710
+ }
1711
+ .chat-panel :global(button.search-toggle[aria-pressed='true']:hover:not(:disabled)) {
1712
+ background: color-mix(in oklch, var(--color-primary) 85%, var(--color-foreground));
1713
+ }
1714
+ :global(.tools-menu) {
1715
+ z-index: 50;
1716
+ box-sizing: border-box;
1717
+ width: min(16rem, calc(100vw - 1rem));
1718
+ display: flex;
1719
+ flex-direction: column;
1720
+ padding: 0.25rem;
1721
+ border: 1px solid var(--color-border);
1722
+ border-radius: var(--kit-radius, var(--radius));
1723
+ background: var(--color-popover);
1724
+ color: var(--color-popover-foreground);
1725
+ box-shadow: 0 4px 12px color-mix(in oklch, var(--color-foreground) 12%, transparent);
1726
+ font-size: var(--kit-font-size-sm, 0.8125rem);
1727
+ }
1728
+ :global(.tools-item) {
1729
+ display: flex;
1730
+ align-items: center;
1731
+ gap: 0.5rem;
1732
+ min-height: 2.25rem;
1733
+ padding: 0.25rem 0.625rem;
1734
+ border: 0;
1735
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
1736
+ background: transparent;
1737
+ color: inherit;
1738
+ font: inherit;
1739
+ text-align: left;
1740
+ cursor: pointer;
1741
+ }
1742
+ :global(.tools-item:hover:not(:disabled)),
1743
+ :global(.tools-item:focus-visible) {
1744
+ background: var(--color-accent);
1745
+ color: var(--color-accent-foreground);
1746
+ outline: none;
1747
+ }
1748
+ :global(.tools-item:disabled) {
1749
+ cursor: not-allowed;
1750
+ opacity: 0.55;
1751
+ }
1752
+ :global(.tools-item svg) {
1753
+ width: 1rem;
1754
+ height: 1rem;
1755
+ flex-shrink: 0;
1756
+ }
1757
+ :global(.tools-item small) {
1758
+ margin-left: auto;
1759
+ color: var(--color-muted-foreground);
1760
+ font-size: 0.75rem;
1761
+ }
1762
+ :global(.tools-item > svg:last-child:not(:first-child)) {
1763
+ margin-left: auto;
1764
+ }
1765
+ :global(.tools-footer) {
1766
+ margin-top: 0.25rem;
1767
+ border-top: 1px solid var(--color-border);
1768
+ border-radius: 0 0 calc(var(--kit-radius, var(--radius)) - 2px) calc(var(--kit-radius, var(--radius)) - 2px);
1769
+ color: var(--color-muted-foreground);
1770
+ }
1771
+ :global(.tools-title) {
1772
+ margin: 0;
1773
+ padding: 0.375rem 0.5rem;
1774
+ font-size: 0.875rem;
1775
+ font-weight: 500;
1776
+ }
1777
+ :global(.tools-order) {
1778
+ display: flex;
1779
+ flex-direction: column;
1780
+ gap: 0.125rem;
1781
+ margin: 0;
1782
+ padding: 0;
1783
+ list-style: none;
1784
+ }
1785
+ :global(.tools-row) {
1786
+ display: flex;
1787
+ align-items: center;
1788
+ gap: 0.5rem;
1789
+ min-height: 2rem;
1790
+ padding: 0 0.375rem;
1791
+ border-radius: calc(var(--kit-radius, var(--radius)) - 2px);
1792
+ font-size: 0.875rem;
1793
+ }
1794
+ :global(.tools-row:hover) {
1795
+ background: var(--color-accent);
1796
+ }
1797
+ :global(.tools-row > svg) {
1798
+ width: 1rem;
1799
+ height: 1rem;
1800
+ flex-shrink: 0;
1801
+ color: var(--color-muted-foreground);
1802
+ }
1803
+ :global(.tools-row > span) {
1804
+ flex: 1;
1805
+ min-width: 0;
1806
+ overflow: hidden;
1807
+ text-overflow: ellipsis;
1808
+ white-space: nowrap;
1809
+ }
1810
+ :global(.tools-grip) {
1811
+ display: inline-flex;
1812
+ padding: 0.125rem;
1813
+ border: 0;
1814
+ border-radius: 0.25rem;
1815
+ background: none;
1816
+ color: var(--color-muted-foreground);
1817
+ cursor: grab;
1818
+ touch-action: none;
1819
+ }
1820
+ :global(.tools-grip:focus-visible) {
1821
+ outline: 2px solid var(--color-ring);
1822
+ }
1823
+ :global(.tools-grip svg) {
1824
+ width: 0.875rem;
1825
+ height: 0.875rem;
1826
+ }
1827
+ .plan-badge {
1828
+ padding: 0.125rem 0.5rem;
1829
+ border: 1px solid var(--color-border);
1830
+ border-radius: 999px;
1831
+ color: var(--color-muted-foreground);
1832
+ font-size: var(--kit-font-size-sm, 0.8125rem);
1833
+ white-space: nowrap;
1834
+ }
1835
+ :global(.mode-menu) {
1836
+ width: min(35rem, calc(100vw - 1rem));
1837
+ padding: 0.375rem;
1838
+ border-radius: 0.75rem;
1839
+ }
1840
+ :global(.mode-option) {
1841
+ display: grid;
1842
+ grid-template-columns: 1rem 7.5rem minmax(0, 1fr) 1rem 0.875rem;
1843
+ align-items: center;
1844
+ column-gap: 0.5rem;
1845
+ min-height: 2.125rem;
1846
+ padding: 0.375rem 0.5rem;
1847
+ border-radius: 0.5rem;
1848
+ font-size: var(--kit-font-size-sm, 0.8125rem);
1849
+ }
1850
+ :global(.mode-option[aria-current='true']) {
1851
+ background: var(--color-muted);
1852
+ }
1853
+ :global(.mode-option > svg) {
1854
+ color: var(--color-muted-foreground);
1855
+ width: 0.875rem;
1856
+ height: 0.875rem;
1857
+ }
1858
+ .mode-name {
1859
+ white-space: nowrap;
1860
+ }
1861
+ .mode-description {
1862
+ color: var(--color-muted-foreground);
1863
+ font-size: var(--kit-font-size-xs, 0.75rem);
1864
+ text-align: right;
1865
+ line-height: 1.5;
1866
+ }
1867
+ .mode-notice,
1868
+ .mode-check {
1869
+ display: flex;
1870
+ align-items: center;
1871
+ justify-content: center;
1872
+ color: var(--color-muted-foreground);
1873
+ }
1874
+ .mode-notice {
1875
+ color: var(--color-warning, var(--color-muted-foreground));
1876
+ }
1877
+ .mode-notice :global(svg),
1878
+ .mode-check :global(svg) {
1879
+ display: block;
1880
+ width: 0.8125rem;
1881
+ height: 0.8125rem;
1882
+ }
1883
+ :global(.mode-danger),
1884
+ :global(.mode-danger) :is(.mode-name, .mode-description, .mode-notice),
1885
+ :global(.mode-option.mode-danger > svg) {
1886
+ color: var(--color-destructive);
1887
+ }
1888
+ .mode-footer {
1889
+ display: flex;
1890
+ justify-content: space-between;
1891
+ align-items: center;
1892
+ gap: 0.75rem;
1893
+ padding: 0.625rem 0.375rem 0.125rem;
1894
+ color: var(--color-muted-foreground);
1895
+ font-size: var(--kit-font-size-xs, 0.75rem);
1896
+ }
1897
+ .mode-keys {
1898
+ display: flex;
1899
+ align-items: center;
1900
+ gap: 0.25rem;
1901
+ }
1902
+ .mode-keys kbd {
1903
+ padding: 0 0.25rem;
1904
+ border-radius: 0.25rem;
1905
+ background: var(--color-muted);
1906
+ font: inherit;
1907
+ }
1908
+ .mode-keys kbd:not(:first-child) {
1909
+ margin-left: 0.375rem;
1910
+ }
1911
+ @media (max-width: 480px) {
1912
+ :global(.mode-option) {
1913
+ grid-template-columns: 1rem minmax(0, 1fr) 1rem 0.875rem;
1914
+ row-gap: 0.125rem;
1915
+ }
1916
+ .mode-description {
1917
+ grid-column: 2;
1918
+ grid-row: 2;
1919
+ text-align: left;
1920
+ }
1921
+ .mode-notice {
1922
+ grid-column: 3;
1923
+ grid-row: 1;
1924
+ }
1925
+ .mode-check {
1926
+ grid-column: 4;
1927
+ grid-row: 1;
1928
+ }
1929
+ }
1930
+ :global(.reasoning-popover) {
1931
+ box-sizing: border-box;
1932
+ width: min(18rem, calc(100vw - 1rem));
1933
+ max-height: calc(100dvh - 1rem);
1934
+ overflow-y: auto;
1935
+ display: flex;
1936
+ flex-direction: column;
1937
+ gap: 0.75rem;
1938
+ padding: 0.75rem;
1939
+ border: 1px solid var(--color-border);
1940
+ border-radius: var(--kit-radius, var(--radius));
1941
+ background: var(--color-popover);
1942
+ color: var(--color-popover-foreground);
1943
+ box-shadow: 0 4px 12px color-mix(in oklch, var(--color-foreground) 12%, transparent);
1944
+ font-size: var(--kit-font-size-sm, 0.8125rem);
1945
+ }
1946
+ .reasoning-heading,
1947
+ .reasoning-scale {
1948
+ display: flex;
1949
+ justify-content: space-between;
1950
+ align-items: center;
1951
+ gap: 0.5rem;
1952
+ }
1953
+ .reasoning-heading p,
1954
+ .reasoning-hint {
1955
+ margin: 0;
1956
+ overflow-wrap: anywhere;
1957
+ }
1958
+ .reasoning-scale,
1959
+ .reasoning-hint {
1960
+ color: var(--color-muted-foreground);
1961
+ }
1962
+ :global(.reasoning-popover input) {
1963
+ accent-color: var(--color-primary);
1964
+ }
1965
+ .reasoning-slider {
1966
+ position: relative;
1967
+ height: 1.75rem;
1968
+ display: flex;
1969
+ align-items: center;
1970
+ }
1971
+ :global(.reasoning-slider input[type='range']) {
1972
+ appearance: none;
1973
+ width: 100%;
1974
+ height: 1.75rem;
1975
+ margin: 0;
1976
+ background: transparent;
1977
+ cursor: pointer;
1978
+ }
1979
+ :global(.reasoning-slider input::-webkit-slider-runnable-track) {
1980
+ height: 0.375rem;
1981
+ border-radius: 999px;
1982
+ background: linear-gradient(
1983
+ to right,
1984
+ var(--color-primary) var(--reasoning-progress),
1985
+ var(--color-muted) var(--reasoning-progress)
1986
+ );
1987
+ }
1988
+ :global(.reasoning-slider input::-moz-range-track) {
1989
+ height: 0.375rem;
1990
+ border-radius: 999px;
1991
+ background: linear-gradient(
1992
+ to right,
1993
+ var(--color-primary) var(--reasoning-progress),
1994
+ var(--color-muted) var(--reasoning-progress)
1995
+ );
1996
+ }
1997
+ :global(.reasoning-slider input::-webkit-slider-thumb) {
1998
+ appearance: none;
1999
+ width: 1.125rem;
2000
+ height: 1.125rem;
2001
+ margin-top: -0.375rem;
2002
+ border: 1px solid var(--color-border);
2003
+ border-radius: 50%;
2004
+ background: var(--color-popover);
2005
+ }
2006
+ :global(.reasoning-slider input::-moz-range-thumb) {
2007
+ width: 1.125rem;
2008
+ height: 1.125rem;
2009
+ box-sizing: border-box;
2010
+ border: 1px solid var(--color-border);
2011
+ border-radius: 50%;
2012
+ background: var(--color-popover);
2013
+ }
2014
+ .reasoning-ticks {
2015
+ position: absolute;
2016
+ inset: 50% 0.5625rem auto;
2017
+ pointer-events: none;
2018
+ }
2019
+ .reasoning-tick {
2020
+ position: absolute;
2021
+ width: 0.25rem;
2022
+ height: 0.25rem;
2023
+ border-radius: 50%;
2024
+ background: var(--color-popover);
2025
+ transform: translate(-50%, -50%);
2026
+ }
2027
+ .reasoning-tick.current {
2028
+ visibility: hidden;
2029
+ }
2030
+ :global(.reasoning-slider input:disabled) {
2031
+ cursor: not-allowed;
2032
+ opacity: 0.5;
2033
+ }
2034
+ :global(.reasoning-popover input:focus-visible) {
2035
+ outline: 2px solid var(--color-ring);
2036
+ outline-offset: 2px;
2037
+ }
2038
+ .reasoning-option {
2039
+ display: flex;
2040
+ align-items: center;
2041
+ gap: 0.5rem;
2042
+ }
2043
+ .skills-control {
2044
+ min-width: 0;
2045
+ max-width: 100%;
2046
+ }
2047
+ .skills-control :global(div:first-child) {
2048
+ flex-wrap: wrap;
2049
+ min-width: 0;
2050
+ }
2051
+ .skills-control :global([data-slot='select-trigger']) {
2052
+ width: auto;
2053
+ max-width: 100%;
2054
+ }
2055
+ .search-status,
2056
+ .draft-recovery {
2057
+ margin: 0;
2058
+ color: var(--color-muted-foreground);
2059
+ font-size: var(--kit-font-size-sm, 0.8125rem);
2060
+ }
2061
+ .draft-recovery {
2062
+ display: flex;
2063
+ flex-wrap: wrap;
2064
+ align-items: center;
2065
+ gap: 0.5rem;
2066
+ }
2067
+ .history-list {
2068
+ display: flex;
2069
+ flex-direction: column;
2070
+ gap: 0.25rem;
2071
+ max-height: 40vh;
2072
+ overflow-y: auto;
2073
+ min-width: 0;
2074
+ }
2075
+ .history-list :global(button) {
2076
+ justify-content: flex-start;
2077
+ min-width: 0;
2078
+ }
2079
+ .history-list span {
2080
+ min-width: 0;
2081
+ overflow: hidden;
2082
+ text-overflow: ellipsis;
2083
+ white-space: nowrap;
2084
+ }
2085
+ :global(.reply-dialog) {
2086
+ max-height: calc(100dvh - 2rem);
2087
+ overflow-y: auto;
2088
+ }
2089
+ :global(.reply-dialog form),
2090
+ :global(.reply-dialog [data-slot='field-group']) {
2091
+ min-width: 0;
2092
+ }
2093
+ :global(.reply-dialog textarea) {
2094
+ max-height: 45dvh;
2095
+ overflow-y: auto;
2096
+ }
2097
+ </style>