@ouraihub/aether-ui 0.1.1 → 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 (34) hide show
  1. package/package.json +2 -2
  2. package/src/chat/ChatComposer.svelte +47 -5
  3. package/src/chat/ChatPanel.css +380 -380
  4. package/src/chat/ChatPanel.svelte +48 -51
  5. package/src/chat/ChatPanelComposer.svelte +44 -25
  6. package/src/chat/ChatPanelHeader.svelte +1 -25
  7. package/src/chat/ChatPanelMode.svelte +3 -3
  8. package/src/chat/ChatPanelToolbar.svelte +14 -3
  9. package/src/chat/ContextUsageStatus.svelte +97 -0
  10. package/src/chat/ModelEffortPicker.svelte +326 -0
  11. package/src/chat/PickerEffort.svelte +71 -0
  12. package/src/chat/ReasoningControl.svelte +10 -112
  13. package/src/chat/ReasoningSlider.svelte +174 -0
  14. package/src/chat/SkillPicker.svelte +1 -0
  15. package/src/chat/UsageControl.svelte +12 -5
  16. package/src/chat/UsageDetails.svelte +19 -3
  17. package/src/chat/chat-attachments.svelte.ts +9 -5
  18. package/src/chat/chat-panel-models.svelte.ts +97 -0
  19. package/src/chat/chat-panel-props.ts +6 -0
  20. package/src/chat/context-status.ts +101 -0
  21. package/src/chat/icons/LICENSE-REMIX +186 -0
  22. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  23. package/src/chat/icons/PathIcon.svelte +8 -0
  24. package/src/chat/icons/effort-icons.ts +65 -0
  25. package/src/chat/icons/status-icons.ts +105 -0
  26. package/src/chat/index.ts +20 -0
  27. package/src/chat/model-effort.ts +177 -0
  28. package/src/chat/test-support/chat-fixture.ts +16 -0
  29. package/src/i18n/en-chat.d.ts +1 -0
  30. package/src/i18n/en-chat.js +201 -0
  31. package/src/i18n/en.ts +24 -174
  32. package/src/i18n/zh-CN-chat.d.ts +1 -0
  33. package/src/i18n/zh-CN-chat.js +190 -0
  34. package/src/i18n/zh-CN.ts +2 -158
@@ -5,7 +5,6 @@
5
5
  isKitClientError,
6
6
  type ModelRef,
7
7
  type SkillRef,
8
- type ModelOption,
9
8
  type ReasoningEffort,
10
9
  type PermissionMode,
11
10
  } from '../ports'
@@ -21,6 +20,8 @@
21
20
  import ChatPanelComposer from './ChatPanelComposer.svelte'
22
21
  import ChatPanelDialogs from './ChatPanelDialogs.svelte'
23
22
  import type { ChatPanelProps } from './chat-panel-props'
23
+ import { ChatPanelModels } from './chat-panel-models.svelte'
24
+ import { modelKey, type PickerValue } from './model-effort'
24
25
 
25
26
  let {
26
27
  purpose = 'chat',
@@ -42,6 +43,9 @@
42
43
  onToolbarChange,
43
44
  reasoningEffort: reasoningEffortProp,
44
45
  onReasoningEffortChange,
46
+ defaultModel = null,
47
+ defaultReasoningEffort = 'default',
48
+ onDefaultModelChange,
45
49
  permissionModes = ['default', 'plan', 'accept_edits'],
46
50
  permissionMode = $bindable('default'),
47
51
  }: ChatPanelProps = $props()
@@ -52,15 +56,18 @@
52
56
  const session = ports.chat ? new RunSession(ports.chat, history) : null
53
57
  // svelte-ignore state_referenced_locally
54
58
  let picked = $state<ModelRef | null>(model)
55
- let modelOptions = $state<ModelOption[] | null>(null)
56
- let modelError = $state<string | null>(null)
57
- 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)
58
64
  const capability = $derived(
59
65
  modelOptions?.find(
60
66
  (item) => item.endpointId === picked?.endpointId && item.modelId === picked?.modelId,
61
67
  ) ?? null,
62
68
  )
63
- // 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).
64
71
  // svelte-ignore state_referenced_locally
65
72
  let chosenEffort = $state<ReasoningEffort>(reasoningEffortProp ?? 'default')
66
73
  const allowedEfforts = $derived(
@@ -111,29 +118,10 @@
111
118
  }
112
119
  let reasoningOpen = $state(false)
113
120
  const noModels = $derived(modelOptions !== null && modelOptions.length === 0)
114
- const errorText = (error: unknown) =>
115
- t(`errors.${isKitClientError(error) ? error.code : 'internal'}`)
116
121
  $effect(() => {
117
- void modelReload
122
+ // A provider save swaps options in place: nulling them would reset the chosen effort.
118
123
  void providersRevision.value
119
- let alive = true
120
- // A provider save swaps options in place: nulling them would reset the chosen effort (docs/41 §3.5).
121
- modelError = null
122
- if (!ports.providers) {
123
- modelOptions = []
124
- return
125
- }
126
- void ports.providers.modelOptions({ modelType: 'chat' }).then(
127
- (list) => {
128
- if (alive) modelOptions = list
129
- },
130
- (error: unknown) => {
131
- if (alive) modelError = errorText(error)
132
- },
133
- )
134
- return () => {
135
- alive = false
136
- }
124
+ untrack(() => void models.load())
137
125
  })
138
126
  $effect(() => {
139
127
  // A model that turns out not to take tools cannot keep a pending generate_image request.
@@ -143,6 +131,20 @@
143
131
  picked = next
144
132
  session?.resetContext()
145
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
+ }
146
148
 
147
149
  let draft = $state('')
148
150
  let draftRevision = 0
@@ -170,7 +172,8 @@
170
172
  generateImage: purpose === 'chat' && generateImage && paintingModel,
171
173
  context: activeContext ? { kind: activeContext.kind, id: activeContext.id } : null,
172
174
  })
173
- // 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.
174
177
  function consumeBase() {
175
178
  const request = { ...base }
176
179
  fullAccess = false
@@ -337,7 +340,11 @@
337
340
 
338
341
  {#if session}
339
342
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions (Esc bubbles from controls) -->
340
- <section class="chat-panel" aria-label={t('chat.title')} {onkeydown}>
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
+ >
341
348
  <ChatSurface
342
349
  label={t('chat.title')}
343
350
  messagesLabel={t('chat.messages')}
@@ -354,17 +361,12 @@
354
361
  historyBusy={historyState.historyBusy}
355
362
  historyOpen={historyState.historyOpen}
356
363
  historyError={historyState.historyError}
357
- {modelOptions}
358
- {modelError}
359
364
  onHistory={() => {
360
365
  historyState.historyOpen = true
361
366
  void historyState.refresh()
362
367
  }}
363
368
  {onClose}
364
369
  onRetryHistory={() => void historyState.retryHistory()}
365
- {picked}
366
- onPickModel={pickModel}
367
- {onOpenSettings}
368
370
  />
369
371
  {/snippet}
370
372
  <ChatPanelMessages
@@ -379,7 +381,7 @@
379
381
  {modelError}
380
382
  {capability}
381
383
  {noModels}
382
- onRetryModels={() => modelReload++}
384
+ onRetryModels={() => void models.load()}
383
385
  {onOpenSettings}
384
386
  {onInsert}
385
387
  {onSaveNote}
@@ -432,10 +434,18 @@
432
434
  {mode}
433
435
  {allowedModes}
434
436
  {chooseMode}
435
- {reasoningEffort}
436
- {allowedEfforts}
437
437
  bind:reasoningOpen
438
- {chooseEffort}
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)}
439
449
  {currentContext}
440
450
  {picked}
441
451
  {onOpenSettings}
@@ -469,7 +479,7 @@
469
479
  {noModels}
470
480
  validReplyModel={historyState.validReplyModel}
471
481
  submitReply={historyState.submitReply}
472
- reloadModels={() => modelReload++}
482
+ reloadModels={() => void models.load()}
473
483
  bind:historyOpen={historyState.historyOpen}
474
484
  bind:renaming={historyState.renaming}
475
485
  bind:renameTitle={historyState.renameTitle}
@@ -481,16 +491,3 @@
481
491
  />
482
492
  </section>
483
493
  {/if}
484
-
485
- <style>
486
- .chat-panel {
487
- display: flex;
488
- flex: 1;
489
- flex-direction: column;
490
- gap: 0.75rem;
491
- min-width: 0;
492
- min-height: 0;
493
- height: 100%;
494
- container-type: size;
495
- }
496
- </style>
@@ -9,17 +9,17 @@
9
9
  ModelOption,
10
10
  ModelRef,
11
11
  PermissionMode,
12
- ReasoningEffort,
13
12
  SkillRef,
14
13
  SkillsPort,
15
14
  } from '../ports'
16
- import { settingsTarget } from '../providers/revision.svelte'
17
15
  import ChatComposer from './ChatComposer.svelte'
18
16
  import ChatPanelMode from './ChatPanelMode.svelte'
19
17
  import ChatPanelToolbar from './ChatPanelToolbar.svelte'
20
- import ReasoningControl from './ReasoningControl.svelte'
18
+ import ModelEffortPicker from './ModelEffortPicker.svelte'
21
19
  import SkillPicker from './SkillPicker.svelte'
22
20
  import UsageDetails from './UsageDetails.svelte'
21
+ import type { PickerModel, PickerValue } from './model-effort'
22
+ import { settingsTarget } from '../providers/revision.svelte'
23
23
  import type { RunSession } from './run-session.svelte'
24
24
 
25
25
  type Attached = ImagePart & { preview: string }
@@ -62,10 +62,16 @@
62
62
  mode,
63
63
  allowedModes,
64
64
  chooseMode,
65
- reasoningEffort,
66
- allowedEfforts,
67
65
  reasoningOpen = $bindable(false),
68
- chooseEffort,
66
+ pickerModels,
67
+ pickerValue,
68
+ pickerDefault,
69
+ pickerRefreshing,
70
+ pickerNotice,
71
+ pickerDisabled,
72
+ onPick,
73
+ onSetDefault,
74
+ onRefresh,
69
75
  currentContext,
70
76
  picked,
71
77
  onOpenSettings,
@@ -105,10 +111,16 @@
105
111
  mode: PermissionMode
106
112
  allowedModes: PermissionMode[]
107
113
  chooseMode: (mode: PermissionMode) => Promise<void>
108
- reasoningEffort: ReasoningEffort
109
- allowedEfforts: ReasoningEffort[]
110
114
  reasoningOpen?: boolean
111
- chooseEffort: (effort: ReasoningEffort) => void
115
+ pickerModels: PickerModel[]
116
+ pickerValue: PickerValue | null
117
+ pickerDefault: PickerValue | null
118
+ pickerRefreshing: boolean
119
+ pickerNotice: string
120
+ pickerDisabled: boolean
121
+ onPick: (value: PickerValue) => void
122
+ onSetDefault?: ((value: PickerValue) => void) | undefined
123
+ onRefresh: (force: boolean) => void
112
124
  currentContext: ContextUsage | null
113
125
  picked: ModelRef | null
114
126
  onOpenSettings?: (() => void) | undefined
@@ -217,28 +229,35 @@
217
229
  onresolved={onSkillResolved}
218
230
  />
219
231
  </div>{/if}
232
+ <ModelEffortPicker
233
+ models={pickerModels}
234
+ value={pickerValue}
235
+ defaultValue={pickerDefault}
236
+ disabled={session.running || pickerDisabled}
237
+ refreshing={pickerRefreshing}
238
+ notice={pickerNotice}
239
+ {i18n}
240
+ bind:open={reasoningOpen}
241
+ onselect={onPick}
242
+ onsetdefault={onSetDefault}
243
+ onrefresh={onRefresh}
244
+ onopensettings={onOpenSettings && picked
245
+ ? () => {
246
+ settingsTarget.value = { endpointId: picked.endpointId, modelId: picked.modelId }
247
+ onOpenSettings()
248
+ }
249
+ : undefined}
250
+ />
220
251
  {/snippet}
221
252
  {#snippet actions()}
222
- <ChatPanelMode {i18n} {mode} {allowedModes} running={session.running} {chooseMode} />
223
- {#if picked}
224
- <ReasoningControl
225
- value={reasoningEffort}
226
- efforts={allowedEfforts}
227
- disabled={session.running}
228
- bind:open={reasoningOpen}
229
- {i18n}
230
- onchange={chooseEffort}
231
- onOpenSettings={onOpenSettings
232
- ? () => {
233
- settingsTarget.value = { endpointId: picked.endpointId, modelId: picked.modelId }
234
- onOpenSettings()
235
- }
236
- : undefined}
237
- />
253
+ {#if purpose !== 'chat'}
254
+ <ChatPanelMode {i18n} {mode} {allowedModes} running={session.running} {chooseMode} />
238
255
  {/if}
239
256
  <UsageDetails
240
257
  context={currentContext}
258
+ contextWindow={pickerModels.find((model) => model.key === pickerValue?.key)?.contextWindow}
241
259
  modelName={capability?.name ?? capability?.modelId ?? ''}
260
+ turns={session.turns}
242
261
  usage={session.turns.at(-1)?.usage ?? null}
243
262
  total={session.cumulativeUsage}
244
263
  cost={session.turns.at(-1)?.cost ?? null}
@@ -4,8 +4,7 @@
4
4
  import { Button } from '#lib/components/ui/button/index.js'
5
5
  import * as Alert from '#lib/components/ui/alert/index.js'
6
6
  import type { KitI18n } from '../i18n'
7
- import type { ConversationClient, ModelOption, ModelRef } from '../ports'
8
- import ModelSelect from '../providers/ModelSelect.svelte'
7
+ import type { ConversationClient } from '../ports'
9
8
  import type { RunSession } from './run-session.svelte'
10
9
 
11
10
  let {
@@ -16,14 +15,9 @@
16
15
  historyBusy,
17
16
  historyOpen,
18
17
  historyError,
19
- modelOptions,
20
- modelError,
21
18
  onHistory,
22
19
  onClose,
23
20
  onRetryHistory,
24
- picked,
25
- onPickModel,
26
- onOpenSettings,
27
21
  }: {
28
22
  i18n: KitI18n
29
23
  title: string
@@ -32,14 +26,9 @@
32
26
  historyBusy: boolean
33
27
  historyOpen: boolean
34
28
  historyError: string | null
35
- modelOptions: ModelOption[] | null
36
- modelError: string | null
37
29
  onHistory: () => void
38
30
  onClose?: (() => void) | undefined
39
31
  onRetryHistory: () => void
40
- picked: ModelRef | null
41
- onPickModel: (model: ModelRef) => void
42
- onOpenSettings?: (() => void) | undefined
43
32
  } = $props()
44
33
  const t = $derived(i18n.t)
45
34
  </script>
@@ -63,16 +52,6 @@
63
52
  onclick={onClose}><XIcon /></Button
64
53
  >{/if}
65
54
  </div>
66
- <div class="model-row">
67
- <ModelSelect
68
- value={picked}
69
- options={modelOptions}
70
- filter={{ modelType: 'chat' }}
71
- disabled={session.running || modelError !== null}
72
- onchange={onPickModel}
73
- {...onOpenSettings ? { onOpenSettings } : {}}
74
- />
75
- </div>
76
55
  </header>
77
56
  {#if historyError && !historyOpen}
78
57
  <Alert.Root variant="destructive">
@@ -126,7 +105,4 @@
126
105
  .title-row :global(button) {
127
106
  flex-shrink: 0;
128
107
  }
129
- .model-row {
130
- min-width: 0;
131
- }
132
108
  </style>
@@ -38,9 +38,9 @@
38
38
  size="sm"
39
39
  class={mode === 'full_access' ? 'mode-danger' : undefined}
40
40
  aria-label={`${t('chat.mode.label')}: ${t(`chat.mode.${mode}`)}`}
41
- ><ShieldIcon data-icon="inline-start" />{t(`chat.mode.${mode}`)}<ChevronDownIcon
42
- data-icon="inline-end"
43
- /></Button
41
+ ><ShieldIcon data-icon="inline-start" /><span data-composer-mode-label>
42
+ {t(`chat.mode.${mode}`)}</span
43
+ ><ChevronDownIcon data-composer-mode-label data-icon="inline-end" /></Button
44
44
  >{/snippet}
45
45
  </DropdownMenu.Trigger>
46
46
  <DropdownMenu.Content side="top" align="end" class="mode-menu">
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte'
2
+ import { onMount, tick } from 'svelte'
3
3
  import { Popover } from 'bits-ui'
4
4
  import PlusIcon from '@lucide/svelte/icons/plus'
5
5
  import GlobeIcon from '@lucide/svelte/icons/globe'
@@ -55,7 +55,17 @@
55
55
  const DEFAULT_TOOLBAR = ['new-conversation', 'web-search']
56
56
  // svelte-ignore state_referenced_locally
57
57
  let pinned = $state<string[]>(toolbar ?? DEFAULT_TOOLBAR)
58
- const visiblePinned = $derived(pinned.filter((id) => tools.includes(id)))
58
+ let compact = $state(false)
59
+ const visiblePinned = $derived(compact ? [] : pinned.filter((id) => tools.includes(id)))
60
+ onMount(() => {
61
+ const shell = fileInput?.closest('.composer-shell')
62
+ if (!shell) return
63
+ const observer = new ResizeObserver(([entry]) => {
64
+ if (entry && entry.contentRect.width > 0) compact = entry.contentRect.width < 400
65
+ })
66
+ observer.observe(shell)
67
+ return () => observer.disconnect()
68
+ })
59
69
  /** Tools usable here; unavailable pinned ids stay stored but hidden (docs/38 §3.2). */
60
70
  const tools = $derived(
61
71
  ['new-conversation', 'web-search', 'attachment', 'generate-image'].filter(
@@ -286,7 +296,8 @@
286
296
  </Popover.Content>
287
297
  </Popover.Portal>
288
298
  </Popover.Root>
289
- {#if generateImage && paintingModel && purpose === 'chat' && !pinned.includes('generate-image')}<Button
299
+ {#if generateImage && paintingModel && purpose === 'chat' && !pinned.includes('generate-image')}
300
+ <Button
290
301
  type="button"
291
302
  variant="secondary"
292
303
  size="sm"
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ // Inline context status beside Send (docs/31 §4.3); opens the shared usage popover.
3
+ import type { Snippet } from 'svelte'
4
+ import { cn } from '#lib/utils.js'
5
+ import type { KitI18n } from '../i18n'
6
+ import type { ContextUsage } from '../ports'
7
+ import UsageControl from './UsageControl.svelte'
8
+ import PathIcon from './icons/PathIcon.svelte'
9
+ import {
10
+ detectPlatform,
11
+ pressure,
12
+ spending,
13
+ summary as contextSummary,
14
+ type Platform,
15
+ type SpendingTurn,
16
+ } from './context-status'
17
+ import { applePath, linuxPath, wandPath, windowsPath } from './icons/status-icons'
18
+
19
+ let {
20
+ context,
21
+ contextWindow = null,
22
+ turns,
23
+ modelName = '',
24
+ i18n,
25
+ platform = detectPlatform(),
26
+ settings,
27
+ details,
28
+ open = $bindable(false),
29
+ }: {
30
+ context: ContextUsage | null
31
+ /** Fallback capacity, e.g. the selected model's window, when the usage has none. */
32
+ contextWindow?: number | null
33
+ turns: readonly SpendingTurn[]
34
+ modelName?: string
35
+ i18n: KitI18n
36
+ platform?: Platform
37
+ settings?: Snippet | undefined
38
+ details?: Snippet | undefined
39
+ open?: boolean
40
+ } = $props()
41
+ const marks: Record<Platform, { name: string; path: string } | null> = {
42
+ windows: { name: 'Windows', path: windowsPath },
43
+ macos: { name: 'macOS', path: applePath },
44
+ linux: { name: 'Linux', path: linuxPath },
45
+ other: null,
46
+ }
47
+ const capacity = $derived(context?.contextWindow ?? contextWindow)
48
+ const text = $derived(contextSummary(context, capacity))
49
+ const spent = $derived(spending(turns))
50
+ const level = $derived(pressure(context, capacity))
51
+ const mark = $derived(marks[platform])
52
+ const label = $derived(
53
+ i18n
54
+ .t('chat.status.label', { platform: mark?.name ?? '', summary: text, spending: spent })
55
+ .trim(),
56
+ )
57
+ </script>
58
+
59
+ <UsageControl
60
+ bind:open
61
+ {i18n}
62
+ {settings}
63
+ {details}
64
+ summaryLabel={label}
65
+ context={{
66
+ tokens: context?.tokens ?? null,
67
+ contextWindow: capacity,
68
+ modelName,
69
+ source: context?.source ?? 'unknown',
70
+ }}
71
+ >
72
+ {#snippet summary()}
73
+ <span
74
+ class={cn(
75
+ 'inline-flex min-w-0 items-center gap-1.5 font-mono text-xs tabular-nums',
76
+ level === 'normal' && 'text-muted-foreground',
77
+ level === 'warning' && 'text-warning',
78
+ level === 'high' &&
79
+ 'text-[color-mix(in_oklch,var(--color-warning)_45%,var(--color-destructive))]',
80
+ level === 'critical' && 'text-destructive',
81
+ )}
82
+ data-pressure={level}
83
+ >
84
+ {#if mark}<PathIcon
85
+ data-composer-capacity
86
+ d={mark.path}
87
+ class="size-3 shrink-0 text-foreground"
88
+ />{/if}
89
+ <span class="shrink-0 whitespace-nowrap"
90
+ >{text.split('/')[0]}<span data-composer-capacity>/{text.split('/')[1]}</span></span
91
+ >
92
+ <PathIcon d={wandPath} class="size-3 shrink-0" />
93
+ <span data-composer-capacity class="mx-1.5 shrink-0 text-border" aria-hidden="true">·</span>
94
+ <span data-composer-capacity class="min-w-0 truncate text-muted-foreground">{spent}</span>
95
+ </span>
96
+ {/snippet}
97
+ </UsageControl>