@ouraihub/aether-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +3 -3
  2. package/src/chat/ApprovalCard.svelte +8 -2
  3. package/src/chat/ChatComposer.svelte +319 -0
  4. package/src/chat/ChatMessage.svelte +262 -0
  5. package/src/chat/ChatPanel.css +386 -0
  6. package/src/chat/ChatPanel.svelte +276 -1880
  7. package/src/chat/ChatPanelApprovals.svelte +28 -0
  8. package/src/chat/ChatPanelComposer.svelte +336 -0
  9. package/src/chat/ChatPanelDialogs.svelte +242 -0
  10. package/src/chat/ChatPanelHeader.svelte +108 -0
  11. package/src/chat/ChatPanelMessages.svelte +166 -0
  12. package/src/chat/ChatPanelMode.svelte +190 -0
  13. package/src/chat/ChatPanelState.svelte.ts +1 -0
  14. package/src/chat/ChatPanelToolbar.svelte +436 -0
  15. package/src/chat/ChatSurface.svelte +168 -0
  16. package/src/chat/ContextUsageStatus.svelte +97 -0
  17. package/src/chat/MessageItem.svelte +111 -214
  18. package/src/chat/ModelEffortPicker.svelte +326 -0
  19. package/src/chat/PickerEffort.svelte +71 -0
  20. package/src/chat/ReasoningControl.svelte +184 -0
  21. package/src/chat/ReasoningSlider.svelte +174 -0
  22. package/src/chat/SkillPicker.svelte +14 -6
  23. package/src/chat/UsageControl.svelte +223 -0
  24. package/src/chat/UsageDetails.svelte +44 -90
  25. package/src/chat/chat-attachments.svelte.ts +78 -0
  26. package/src/chat/chat-panel-history.svelte.ts +430 -0
  27. package/src/chat/chat-panel-models.svelte.ts +97 -0
  28. package/src/chat/chat-panel-props.ts +38 -0
  29. package/src/chat/context-status.ts +101 -0
  30. package/src/chat/icons/LICENSE-REMIX +186 -0
  31. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  32. package/src/chat/icons/PathIcon.svelte +8 -0
  33. package/src/chat/icons/effort-icons.ts +65 -0
  34. package/src/chat/icons/status-icons.ts +105 -0
  35. package/src/chat/index.ts +25 -1
  36. package/src/chat/model-effort.ts +177 -0
  37. package/src/chat/run-session-history.ts +56 -0
  38. package/src/chat/run-session-types.ts +69 -0
  39. package/src/chat/run-session.svelte.ts +73 -135
  40. package/src/chat/test-support/chat-fixture.ts +194 -0
  41. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  42. package/src/chat/test-support/run-session-fixture.ts +112 -0
  43. package/src/dev/Demo.svelte +3 -1
  44. package/src/i18n/en-chat.d.ts +1 -0
  45. package/src/i18n/en-chat.js +201 -0
  46. package/src/i18n/en.ts +58 -180
  47. package/src/i18n/index.ts +7 -2
  48. package/src/i18n/zh-CN-chat.d.ts +1 -0
  49. package/src/i18n/zh-CN-chat.js +190 -0
  50. package/src/i18n/zh-CN.ts +8 -153
  51. package/src/logs/LogPanel.svelte +9 -3
  52. package/src/logs/LogSettings.svelte +3 -1
  53. package/src/mcp/McpClientEditor.svelte +204 -0
  54. package/src/mcp/McpClientSettings.svelte +57 -130
  55. package/src/mcp/McpServerSettings.svelte +47 -11
  56. package/src/mcp/input.ts +2 -1
  57. package/src/ports/contract.ts +11 -2
  58. package/src/ports/fake-optional.ts +81 -0
  59. package/src/ports/fake.ts +90 -86
  60. package/src/ports/search.ts +11 -2
  61. package/src/ports/test-support/fake-fixture.ts +63 -0
  62. package/src/providers/EndpointKeysDialog.svelte +27 -13
  63. package/src/providers/EndpointModels.svelte +311 -0
  64. package/src/providers/EndpointPanel.svelte +33 -258
  65. package/src/providers/ModelPickerDialog.svelte +59 -16
  66. package/src/providers/ModelPricing.svelte +125 -0
  67. package/src/providers/ModelSelect.svelte +22 -8
  68. package/src/providers/ModelSettingsDialog.svelte +61 -126
  69. package/src/providers/ProviderDialogs.svelte +120 -0
  70. package/src/providers/ProviderList.svelte +159 -0
  71. package/src/providers/ProvidersSettings.svelte +67 -207
  72. package/src/providers/RequestConfigDialog.svelte +40 -12
  73. package/src/providers/SecretInput.svelte +11 -3
  74. package/src/providers/draft.ts +39 -13
  75. package/src/providers/edit-session.ts +31 -7
  76. package/src/providers/reasoning.ts +18 -4
  77. package/src/providers/revision.svelte.ts +3 -1
  78. package/src/providers/test-support/providers-fixture.ts +120 -0
  79. package/src/search/SearchAdvancedTools.svelte +182 -0
  80. package/src/search/SearchProviderCard.svelte +195 -0
  81. package/src/search/SearchSettings.svelte +62 -265
  82. package/src/search/settings-flow.ts +8 -3
  83. package/src/skills/SkillAdd.svelte +51 -18
  84. package/src/skills/SkillAuthoring.svelte +69 -20
  85. package/src/skills/SkillEditor.svelte +13 -3
  86. package/src/skills/SkillPreviewDialog.svelte +19 -5
  87. package/src/skills/SkillsSettings.svelte +11 -4
  88. package/src/skills/test-support/skills-fixture.ts +139 -0
  89. package/src/tauri/index.ts +23 -7
  90. package/src/web/index.ts +90 -25
@@ -1,61 +1,27 @@
1
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'
2
+ import { onDestroy, untrack } from 'svelte'
39
3
  import { getKitContext } from '../context'
40
4
  import {
41
5
  isKitClientError,
42
- type ConversationSummary,
43
- type Conversation,
44
6
  type ModelRef,
45
7
  type SkillRef,
46
- type ModelOption,
47
- type ImagePart,
48
- type ImageRef,
49
8
  type ReasoningEffort,
50
9
  type PermissionMode,
51
10
  } 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'
11
+ import { providersRevision } from '../providers/revision.svelte'
12
+ import { RunSession } from './run-session.svelte'
13
+ import { ChatPanelHistoryState } from './chat-panel-history.svelte'
14
+ import { ChatAttachments, hasImages, type AttachedImage } from './chat-attachments.svelte'
15
+ import ChatSurface from './ChatSurface.svelte'
16
+ import ChatPanelApprovals from './ChatPanelApprovals.svelte'
57
17
  import { nearestEffort, REASONING_EFFORTS } from '../providers/reasoning'
58
- import SkillPicker from './SkillPicker.svelte'
18
+ import ChatPanelHeader from './ChatPanelHeader.svelte'
19
+ import ChatPanelMessages from './ChatPanelMessages.svelte'
20
+ import ChatPanelComposer from './ChatPanelComposer.svelte'
21
+ import ChatPanelDialogs from './ChatPanelDialogs.svelte'
22
+ import type { ChatPanelProps } from './chat-panel-props'
23
+ import { ChatPanelModels } from './chat-panel-models.svelte'
24
+ import { modelKey, type PickerValue } from './model-effort'
59
25
 
60
26
  let {
61
27
  purpose = 'chat',
@@ -77,58 +43,36 @@
77
43
  onToolbarChange,
78
44
  reasoningEffort: reasoningEffortProp,
79
45
  onReasoningEffortChange,
46
+ defaultModel = null,
47
+ defaultReasoningEffort = 'default',
48
+ onDefaultModelChange,
80
49
  permissionModes = ['default', 'plan', 'accept_edits'],
81
50
  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()
51
+ }: ChatPanelProps = $props()
112
52
 
113
53
  const { ports, i18n, notify } = getKitContext()
114
- let viewGeneration = 0
115
- let listGeneration = 0
116
- let disposed = false
117
54
  const { t } = i18n
118
55
  const history = ports.conversations
119
56
  const session = ports.chat ? new RunSession(ports.chat, history) : null
120
57
  // svelte-ignore state_referenced_locally
121
58
  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)
59
+ const errorText = (error: unknown) =>
60
+ t(`errors.${isKitClientError(error) ? error.code : 'internal'}`)
61
+ const models = new ChatPanelModels(ports.providers, i18n, errorText)
62
+ const modelOptions = $derived(models.options)
63
+ const modelError = $derived(models.error)
125
64
  const capability = $derived(
126
- modelOptions?.find((item) => item.endpointId === picked?.endpointId && item.modelId === picked?.modelId) ?? null,
65
+ modelOptions?.find(
66
+ (item) => item.endpointId === picked?.endpointId && item.modelId === picked?.modelId,
67
+ ) ?? null,
127
68
  )
128
- // The saved choice survives model switches; each model sends its nearest allowed effort (docs/28 RT3).
69
+ // The saved choice survives model switches; each model sends its nearest allowed effort
70
+ // (docs/28 RT3).
129
71
  // svelte-ignore state_referenced_locally
130
72
  let chosenEffort = $state<ReasoningEffort>(reasoningEffortProp ?? 'default')
131
- const allowedEfforts = $derived(REASONING_EFFORTS.filter((effort) => capability?.reasoning?.efforts.includes(effort)))
73
+ const allowedEfforts = $derived(
74
+ REASONING_EFFORTS.filter((effort) => capability?.reasoning?.efforts.includes(effort)),
75
+ )
132
76
  const reasoningEffort = $derived(nearestEffort(chosenEffort, allowedEfforts))
133
77
  function chooseEffort(effort: ReasoningEffort) {
134
78
  if (session?.running || effort === chosenEffort) return
@@ -136,7 +80,10 @@
136
80
  onReasoningEffortChange?.(effort)
137
81
  }
138
82
  const MODES: PermissionMode[] = ['default', 'plan', 'accept_edits', 'auto', 'full_access']
139
- const allowedModes = $derived(MODES.filter((mode) => permissionModes.includes(mode)))
83
+ const isAgent = $derived(purpose === 'agent')
84
+ const allowedModes: PermissionMode[] = $derived(
85
+ isAgent ? MODES.filter((mode) => permissionModes.includes(mode)) : ['default'],
86
+ )
140
87
  let fullAccess = $state(false)
141
88
  const mode: PermissionMode = $derived(
142
89
  fullAccess && allowedModes.includes('full_access')
@@ -147,7 +94,7 @@
147
94
  )
148
95
  async function chooseMode(next: PermissionMode) {
149
96
  if (session?.running || next === mode || !allowedModes.includes(next)) return
150
- const generation = viewGeneration
97
+ const generation = historyState.viewGeneration
151
98
  if (next === 'full_access') {
152
99
  if (
153
100
  !(await notify.confirm({
@@ -157,7 +104,12 @@
157
104
  }))
158
105
  )
159
106
  return
160
- if (!disposed && generation === viewGeneration && !session?.running && allowedModes.includes(next))
107
+ if (
108
+ !historyState.disposed &&
109
+ generation === historyState.viewGeneration &&
110
+ !session?.running &&
111
+ allowedModes.includes(next)
112
+ )
161
113
  fullAccess = true
162
114
  } else {
163
115
  fullAccess = false
@@ -165,53 +117,38 @@
165
117
  }
166
118
  }
167
119
  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
120
  const noModels = $derived(modelOptions !== null && modelOptions.length === 0)
175
- const errorText = (error: unknown) => t(`errors.${isKitClientError(error) ? error.code : 'internal'}`)
176
121
  $effect(() => {
177
- void modelReload
122
+ // A provider save swaps options in place: nulling them would reset the chosen effort.
178
123
  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
- }
124
+ untrack(() => void models.load())
197
125
  })
198
126
  $effect(() => {
199
127
  // A model that turns out not to take tools cannot keep a pending generate_image request.
200
128
  if (capability?.supportsTools === false) generateImage = false
201
129
  })
202
- function commitReasoning(event: Event) {
203
- const effort = allowedEfforts[Number((event.currentTarget as HTMLInputElement).value)]
204
- if (effort) chooseEffort(effort)
205
- }
206
130
  function pickModel(next: ModelRef) {
207
131
  picked = next
208
132
  session?.resetContext()
209
133
  }
134
+ const pickerValue = $derived(
135
+ picked ? { key: modelKey(picked.endpointId, picked.modelId), effort: reasoningEffort } : null,
136
+ )
137
+ const pickerDefault = $derived(
138
+ defaultModel
139
+ ? {
140
+ key: modelKey(defaultModel.endpointId, defaultModel.modelId),
141
+ effort: defaultReasoningEffort,
142
+ }
143
+ : null,
144
+ )
145
+ function pickValue(value: PickerValue) {
146
+ if (!session?.running) models.select(value, picked, chosenEffort, pickModel, chooseEffort)
147
+ }
210
148
 
211
149
  let draft = $state('')
212
150
  let draftRevision = 0
213
151
  let contextRevision = 0
214
- let expanded = $state(false)
215
152
  // svelte-ignore state_referenced_locally
216
153
  let ownSkills = $state<SkillRef[]>(skills ?? [])
217
154
  const selected = $derived(skills ?? ownSkills)
@@ -229,458 +166,114 @@
229
166
  scope: scope ?? null,
230
167
  language: i18n.locale,
231
168
  model: picked,
232
- skills: selected,
169
+ skills: isAgent ? selected : [],
233
170
  reasoningEffort,
234
171
  permissionMode: mode,
235
172
  generateImage: purpose === 'chat' && generateImage && paintingModel,
236
173
  context: activeContext ? { kind: activeContext.kind, id: activeContext.id } : null,
237
174
  })
238
- // Consume before awaiting run acceptance, including retries and translations. Never persist full access.
175
+ // Consume before awaiting run acceptance, including retries and translations.
176
+ // Never persist full access.
239
177
  function consumeBase() {
240
178
  const request = { ...base }
241
179
  fullAccess = false
242
180
  return request
243
181
  }
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
182
 
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'),
183
+ let scrollRevision = $state(0)
184
+ const historyState = new ChatPanelHistoryState(
185
+ {
186
+ history,
187
+ getSession: () => session,
188
+ getModelOptions: () => modelOptions,
189
+ getModelError: () => modelError,
190
+ setDraft: (value) => {
191
+ draft = value
192
+ },
193
+ bumpDraftRevision: () => draftRevision++,
194
+ setFullAccess: (value) => {
195
+ fullAccess = value
196
+ },
197
+ getScope: () => scope,
198
+ getLocale: () => i18n.locale,
199
+ onConversationChange: (id) => onConversationChange?.(id),
200
+ bumpScrollRevision: () => scrollRevision++,
201
+ notify,
202
+ t,
203
+ errorText,
204
+ consumeBase,
205
+ discardRecovery: () => discardRecovery(),
206
+ },
207
+ untrack(() => conversationId),
258
208
  )
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
- })
209
+ $effect(() => () => historyState.dispose())
280
210
  $effect(() => {
281
- const id = session?.conversationId ?? null
282
- if (!restored || restoreBlocked || id === reported) return
283
- reported = id
284
- onConversationChange?.(id)
211
+ if (session && !session.running) void historyState.refresh()
285
212
  })
286
- // svelte-ignore state_referenced_locally
287
- void restore(conversationId)
213
+ $effect(() => historyState.syncConversation())
214
+ void historyState.restore(untrack(() => conversationId))
288
215
 
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
- }
216
+ let followKey = $state(0)
400
217
 
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,
218
+ let contentRevision = 0
219
+ const pendingApprovals = $derived(
220
+ session?.running
221
+ ? session.turns.flatMap((turn) => turn.tools.filter((tool) => tool.pending !== null))
222
+ : [],
423
223
  )
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
224
  $effect.pre(() => {
580
- if (!session || !scroller) return
581
- for (const turn of session.turns) {
225
+ for (const turn of session?.turns ?? []) {
582
226
  void turn.text
583
227
  void turn.reasoningText
584
228
  void turn.status
585
229
  void turn.error
586
230
  void turn.usage
587
231
  void turn.search
232
+ void turn.generated.length
588
233
  for (const tool of turn.tools) {
589
234
  void tool.pending
590
235
  void tool.summary
591
236
  void tool.outcome
592
237
  }
593
238
  }
594
- void expanded
595
- if (untrack(() => follow))
596
- void tick().then(() => {
597
- if (follow) toBottom()
598
- })
239
+ followKey = ++contentRevision
599
240
  })
600
241
  function onkeydown(e: KeyboardEvent) {
601
242
  if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return
602
243
  // Popover content renders inline, so Escape inside it bubbles here; let the layer close itself.
603
244
  if (
604
245
  reasoningOpen ||
605
- historyOpen ||
606
- document.querySelector('[data-popover-content], [data-slot="select-content"], [role="dialog"]')
246
+ historyState.historyOpen ||
247
+ document.querySelector(
248
+ '[data-popover-content], [data-slot="select-content"], [role="dialog"]',
249
+ )
607
250
  )
608
251
  return
609
252
  if (onClose && !session?.running) onClose()
610
253
  }
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
- ),
254
+ const attachments = new ChatAttachments(() => capability?.canVision === true, notify, i18n)
255
+ type Attached = AttachedImage
256
+ const currentContext = $derived(
257
+ draft.trim() === '' &&
258
+ attachments.images.length === 0 &&
259
+ session?.contextUsage &&
260
+ session.contextUsage.endpointId === picked?.endpointId &&
261
+ session.contextUsage.modelId === picked?.modelId
262
+ ? session.contextUsage
263
+ : null,
631
264
  )
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
- }
265
+ const reading = $derived(attachments.reading)
266
+ const vision = $derived(capability?.canVision === true)
674
267
  const canSend = $derived(
675
268
  session !== null &&
676
269
  !session.running &&
677
- restored &&
678
- !restoreBlocked &&
679
- !historyBusy &&
270
+ historyState.restored &&
271
+ !historyState.restoreBlocked &&
272
+ !historyState.historyBusy &&
680
273
  capability !== null &&
681
274
  modelError === null &&
682
275
  draft.trim() !== '' &&
683
- (images.length === 0 || vision) &&
276
+ (attachments.images.length === 0 || vision) &&
684
277
  reading === 0,
685
278
  )
686
279
  let recovery = $state<{
@@ -694,9 +287,9 @@
694
287
  recovery = null
695
288
  }
696
289
  function recoverDraft() {
697
- if (!recovery || draft !== '' || images.length > 0) return
290
+ if (!recovery || draft !== '' || attachments.images.length > 0) return
698
291
  draft = recovery.draft
699
- images = recovery.images
292
+ attachments.images = recovery.images
700
293
  draftRevision++
701
294
  if (contextKey === recovery.contextKey) droppedKey = recovery.droppedKey
702
295
  contextRevision++
@@ -704,33 +297,33 @@
704
297
  }
705
298
  async function send() {
706
299
  if (!session || !canSend) return
707
- const generation = viewGeneration
300
+ const generation = historyState.viewGeneration
708
301
  const original = draft
709
- const attached = images
302
+ const attached = attachments.images
710
303
  const revision = draftRevision
711
304
  const contextVersion = contextRevision
712
305
  const removed = droppedKey
713
306
  const key = contextKey
714
307
  draft = ''
715
- images = []
308
+ attachments.images = []
716
309
  discardRecovery()
717
- follow = true
310
+ scrollRevision++
718
311
  const accepted = await session.send(
719
312
  consumeBase(),
720
313
  original.trim(),
721
314
  webSearch,
722
315
  attached.map(({ mime, data }) => ({ mime, data })),
723
316
  )
724
- if (disposed || generation !== viewGeneration) {
317
+ if (historyState.disposed || generation !== historyState.viewGeneration) {
725
318
  for (const image of attached) URL.revokeObjectURL(image.preview)
726
319
  return
727
320
  }
728
321
  if (accepted) {
729
322
  for (const image of attached) URL.revokeObjectURL(image.preview)
730
323
  if (contextRevision === contextVersion && contextKey === key) droppedKey = null
731
- } else if (draftRevision === revision && images.length === 0) {
324
+ } else if (draftRevision === revision && attachments.images.length === 0) {
732
325
  draft = original
733
- images = attached
326
+ attachments.images = attached
734
327
  if (contextRevision === contextVersion && contextKey === key) droppedKey = removed
735
328
  } else {
736
329
  recovery = { draft: original, images: attached, droppedKey: removed, contextKey: key }
@@ -738,1360 +331,163 @@
738
331
  }
739
332
 
740
333
  onDestroy(() => {
741
- for (const image of images) URL.revokeObjectURL(image.preview)
334
+ for (const image of attachments.images) URL.revokeObjectURL(image.preview)
742
335
  discardRecovery()
743
336
  })
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
- }
337
+ const removeImage = attachments.remove
338
+ const attach = attachments.attach
784
339
  </script>
785
340
 
786
341
  {#if session}
787
342
  <!-- 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
- }}
343
+ <section
344
+ class="flex min-h-0 min-w-0 flex-1 flex-col gap-3 h-full [container-type:size]"
345
+ aria-label={t('chat.title')}
346
+ {onkeydown}
347
+ >
348
+ <ChatSurface
349
+ label={t('chat.title')}
350
+ messagesLabel={t('chat.messages')}
351
+ latestLabel={t('chat.backToBottom')}
352
+ {followKey}
353
+ scrollKey={`${session.conversationId ?? ''}:${scrollRevision}`}
946
354
  >
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
355
+ {#snippet header()}
356
+ <ChatPanelHeader
357
+ {i18n}
358
+ title={historyState.title}
359
+ {session}
360
+ {history}
361
+ historyBusy={historyState.historyBusy}
362
+ historyOpen={historyState.historyOpen}
363
+ historyError={historyState.historyError}
364
+ onHistory={() => {
365
+ historyState.historyOpen = true
366
+ void historyState.refresh()
958
367
  }}
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
- }}
368
+ {onClose}
369
+ onRetryHistory={() => void historyState.retryHistory()}
370
+ />
371
+ {/snippet}
372
+ <ChatPanelMessages
373
+ {i18n}
374
+ {session}
375
+ groups={historyState.groups}
376
+ {purpose}
377
+ {history}
378
+ restoreBlocked={historyState.restoreBlocked}
379
+ actionBusy={historyState.actionBusy}
380
+ {modelOptions}
381
+ {modelError}
382
+ {capability}
383
+ {noModels}
384
+ onRetryModels={() => void models.load()}
385
+ {onOpenSettings}
386
+ {onInsert}
387
+ {onSaveNote}
388
+ {onExportMarkdown}
389
+ sourceLabel={historyState.sourceLabel}
390
+ sourceModel={historyState.sourceModel}
391
+ canEdit={historyState.canEdit}
392
+ onRetry={(turn) => void historyState.retryReply(turn)}
393
+ onEdit={historyState.editReply}
394
+ onBranch={(turn) => void historyState.mutateReply(turn, 'branch')}
395
+ onCopyConversation={(turn) => void historyState.mutateReply(turn, 'copy')}
396
+ onSwitchModel={(turn, next) => void historyState.switchModel(turn, next)}
397
+ onTranslate={historyState.translateDialog}
398
+ onDelete={(turn) => void historyState.mutateReply(turn, 'remove')}
399
+ onUseContext={(turn) => void historyState.mutateReply(turn, 'select')}
1013
400
  />
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()
401
+ {#snippet composer()}
402
+ <ChatPanelComposer
403
+ {i18n}
404
+ {session}
405
+ {purpose}
406
+ bind:draft
407
+ {canSend}
408
+ {activeContext}
409
+ images={attachments.images}
410
+ {vision}
411
+ {recovery}
412
+ bind:webSearch
413
+ bind:generateImage
414
+ toolbar={toolbarProp}
415
+ {onToolbarChange}
416
+ {paintingModel}
417
+ {capability}
418
+ historyBusy={historyState.historyBusy}
419
+ onNewConversation={historyState.newConversation}
420
+ {attach}
421
+ onDraftInput={() => draftRevision++}
422
+ onRemoveImage={removeImage}
423
+ onRecoverDraft={recoverDraft}
424
+ onDropContext={() => {
425
+ droppedKey = contextKey
426
+ contextRevision++
1487
427
  }}
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>
428
+ hasSearch={!!ports.search}
429
+ {selected}
430
+ {skillsBudget}
431
+ skillsPort={isAgent ? ports.skills : undefined}
432
+ onSkillsChange={setSkills}
433
+ onSkillResolved={() => (session.skillChanged = null)}
434
+ {mode}
435
+ {allowedModes}
436
+ {chooseMode}
437
+ bind:reasoningOpen
438
+ pickerModels={models.picker}
439
+ {pickerValue}
440
+ {pickerDefault}
441
+ pickerRefreshing={models.refreshing}
442
+ pickerNotice={models.notice}
443
+ pickerDisabled={modelError !== null}
444
+ onPick={pickValue}
445
+ onSetDefault={onDefaultModelChange
446
+ ? (value) => void models.saveDefault(value, onDefaultModelChange)
447
+ : undefined}
448
+ onRefresh={(force) => void models.load(force)}
449
+ {currentContext}
450
+ {picked}
451
+ {onOpenSettings}
452
+ onSend={() => void send()}
453
+ onStop={() => session.stop()}
454
+ {hasImages}
455
+ override={pendingApprovals.length ? approvals : undefined}
456
+ />
457
+ {/snippet}
458
+ </ChatSurface>
459
+ {#snippet approvals()}
460
+ <ChatPanelApprovals {session} tools={pendingApprovals} {i18n} />
461
+ {/snippet}
462
+ <ChatPanelDialogs
463
+ {i18n}
464
+ {history}
465
+ {session}
466
+ conversations={historyState.conversations}
467
+ title={historyState.title}
468
+ historyLoading={historyState.historyLoading}
469
+ historyBusy={historyState.historyBusy}
470
+ historyError={historyState.historyError}
471
+ retryHistory={historyState.retryHistory}
472
+ open={historyState.open}
473
+ rename={historyState.rename}
474
+ remove={historyState.remove}
475
+ actionBusy={historyState.actionBusy}
476
+ replyDialog={historyState.replyDialog}
477
+ {modelOptions}
478
+ {modelError}
479
+ {noModels}
480
+ validReplyModel={historyState.validReplyModel}
481
+ submitReply={historyState.submitReply}
482
+ reloadModels={() => void models.load()}
483
+ bind:historyOpen={historyState.historyOpen}
484
+ bind:renaming={historyState.renaming}
485
+ bind:renameTitle={historyState.renameTitle}
486
+ bind:replyOpen={historyState.replyOpen}
487
+ bind:editDraft={historyState.editDraft}
488
+ bind:replyModel={historyState.replyModel}
489
+ bind:targetLanguage={historyState.targetLanguage}
490
+ replyError={historyState.replyError}
491
+ />
1541
492
  </section>
1542
493
  {/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>