@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.1-develop.3246.1.dbef9d7392",
3
+ "version": "0.6.1-develop.3256.1.fe3dec2464",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -140,12 +140,12 @@
140
140
  "remark-gfm": "^4.0.1"
141
141
  },
142
142
  "peerDependencies": {
143
- "@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
143
+ "@open-mercato/shared": "0.6.1-develop.3256.1.fe3dec2464",
144
144
  "react": ">=18.0.0",
145
145
  "react-dom": ">=18.0.0"
146
146
  },
147
147
  "devDependencies": {
148
- "@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
148
+ "@open-mercato/shared": "0.6.1-develop.3256.1.fe3dec2464",
149
149
  "@testing-library/dom": "^10.4.1",
150
150
  "@testing-library/jest-dom": "^6.9.1",
151
151
  "@testing-library/react": "^16.3.1",
@@ -131,6 +131,7 @@ const DEFAULT_AGENTS_ENDPOINT = '/api/ai_assistant/ai/agents'
131
131
  const DEFAULT_HEALTH_ENDPOINT = '/api/ai_assistant/health'
132
132
  const AI_ASSISTANT_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/overview'
133
133
  const AI_ASSISTANT_SETTINGS_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/settings'
134
+ export const AI_ASSISTANT_LAUNCHER_OPEN_EVENT = 'om:open-ai-assistant-launcher'
134
135
 
135
136
  function isMutationCapable(policy: string | null | undefined): boolean {
136
137
  return policy === 'confirm-required' || policy === 'destructive-confirm-required'
@@ -319,6 +320,13 @@ export function AiAssistantLauncher({
319
320
  setPickerOpen(true)
320
321
  }, [])
321
322
 
323
+ React.useEffect(() => {
324
+ if (typeof window === 'undefined') return
325
+ const listener = () => openPicker()
326
+ window.addEventListener(AI_ASSISTANT_LAUNCHER_OPEN_EVENT, listener)
327
+ return () => window.removeEventListener(AI_ASSISTANT_LAUNCHER_OPEN_EVENT, listener)
328
+ }, [openPicker])
329
+
322
330
  const handleSelectAgent = React.useCallback((agent: AiAssistantLauncherAgent) => {
323
331
  if (dock.state.assistant?.agent === agent.id) {
324
332
  dock.dock(dock.state.assistant)
package/src/ai/AiChat.tsx CHANGED
@@ -26,7 +26,13 @@ import { Button } from '../primitives/button'
26
26
  import { IconButton } from '../primitives/icon-button'
27
27
  import { Label } from '../primitives/label'
28
28
  import { Textarea } from '../primitives/textarea'
29
+ import { apiCall } from '../backend/utils/apiCall'
29
30
  import { parseAiContentSegments } from './AiMessageContent'
31
+ import {
32
+ ModelPicker,
33
+ type ModelPickerProvider,
34
+ type ModelPickerValue,
35
+ } from './ModelPicker'
30
36
  import { RecordCard } from './records/RecordCard'
31
37
  import {
32
38
  defaultAiUiPartRegistry,
@@ -48,6 +54,113 @@ import { useAiShortcuts } from './useAiShortcuts'
48
54
  // browsers). Images larger than this still upload + send to the LLM as inline
49
55
  // base64 server-side; only the in-chat preview is dropped on reload.
50
56
  const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024
57
+ const COMPACT_FOOTER_MAX_WIDTH = 640
58
+
59
+ const MODEL_PICKER_STORAGE_PREFIX = 'om-ai-model-picker:'
60
+
61
+ function readModelPickerValue(agentId: string): ModelPickerValue | null {
62
+ if (typeof window === 'undefined') return null
63
+ try {
64
+ const raw = window.localStorage.getItem(`${MODEL_PICKER_STORAGE_PREFIX}${agentId}`)
65
+ if (!raw) return null
66
+ const parsed = JSON.parse(raw) as unknown
67
+ if (
68
+ parsed &&
69
+ typeof parsed === 'object' &&
70
+ typeof (parsed as Record<string, unknown>).providerId === 'string' &&
71
+ typeof (parsed as Record<string, unknown>).modelId === 'string'
72
+ ) {
73
+ const value = parsed as ModelPickerValue
74
+ return { providerId: value.providerId, modelId: value.modelId }
75
+ }
76
+ return null
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ function writeModelPickerValue(agentId: string, value: ModelPickerValue | null): void {
83
+ if (typeof window === 'undefined') return
84
+ try {
85
+ const key = `${MODEL_PICKER_STORAGE_PREFIX}${agentId}`
86
+ if (value === null) {
87
+ window.localStorage.removeItem(key)
88
+ } else {
89
+ window.localStorage.setItem(key, JSON.stringify({ providerId: value.providerId, modelId: value.modelId }))
90
+ }
91
+ } catch {
92
+ // Quota exceeded / privacy mode — silently ignore.
93
+ }
94
+ }
95
+
96
+ interface ModelsApiResponse {
97
+ agentId: string
98
+ allowRuntimeModelOverride: boolean
99
+ defaultProviderId: string | null
100
+ defaultModelId: string | null
101
+ defaultProviderName?: string | null
102
+ defaultModelName?: string | null
103
+ providers: ModelPickerProvider[]
104
+ }
105
+
106
+ function useAgentModels(agent: string): {
107
+ providers: ModelPickerProvider[]
108
+ allowRuntimeModelOverride: boolean
109
+ defaultLabel: string | null
110
+ loaded: boolean
111
+ } {
112
+ const [providers, setProviders] = React.useState<ModelPickerProvider[]>([])
113
+ const [allowRuntimeModelOverride, setAllowRuntimeModelOverride] = React.useState(false)
114
+ const [defaultLabel, setDefaultLabel] = React.useState<string | null>(null)
115
+ const [loaded, setLoaded] = React.useState(false)
116
+
117
+ React.useEffect(() => {
118
+ const modelsUrl = `/api/ai_assistant/ai/agents/${encodeURIComponent(agent)}/models`
119
+ setLoaded(false)
120
+ setDefaultLabel(null)
121
+ void apiCall<ModelsApiResponse>(modelsUrl).then((result) => {
122
+ if (!result.ok || !result.result) {
123
+ setLoaded(true)
124
+ return
125
+ }
126
+ setAllowRuntimeModelOverride(result.result.allowRuntimeModelOverride)
127
+ setProviders(result.result.providers)
128
+ setDefaultLabel(
129
+ result.result.defaultProviderName && result.result.defaultModelName
130
+ ? `${result.result.defaultProviderName} / ${result.result.defaultModelName}`
131
+ : result.result.defaultProviderId && result.result.defaultModelId
132
+ ? `${result.result.defaultProviderId} / ${result.result.defaultModelId}`
133
+ : null,
134
+ )
135
+ setLoaded(true)
136
+ })
137
+ }, [agent])
138
+
139
+ return { providers, allowRuntimeModelOverride, defaultLabel, loaded }
140
+ }
141
+
142
+ function firstAvailableModelPickerValue(
143
+ providers: ModelPickerProvider[],
144
+ ): ModelPickerValue | null {
145
+ for (const provider of providers) {
146
+ const model = provider.models[0]
147
+ if (model) {
148
+ return { providerId: provider.id, modelId: model.id }
149
+ }
150
+ }
151
+ return null
152
+ }
153
+
154
+ function isModelPickerValueAvailable(
155
+ value: ModelPickerValue,
156
+ providers: ModelPickerProvider[],
157
+ ): boolean {
158
+ return providers.some(
159
+ (provider) =>
160
+ provider.id === value.providerId &&
161
+ provider.models.some((model) => model.id === value.modelId),
162
+ )
163
+ }
51
164
 
52
165
  async function readFileAsDataUrl(file: File): Promise<string | undefined> {
53
166
  if (!file.type.startsWith('image/')) return undefined
@@ -158,6 +271,8 @@ export interface AiChatProps {
158
271
  welcomeTitle?: string
159
272
  /** Welcome description shown below the heading. */
160
273
  welcomeDescription?: string
274
+ /** Initial compact composer state used before the footer has been measured. */
275
+ defaultCompactFooter?: boolean
161
276
  }
162
277
 
163
278
  interface ServerEmittedUiPartRef {
@@ -718,6 +833,7 @@ export function AiChat({
718
833
  contextItems,
719
834
  welcomeTitle,
720
835
  welcomeDescription,
836
+ defaultCompactFooter = false,
721
837
  }: AiChatProps) {
722
838
  const t = useT()
723
839
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
@@ -740,6 +856,26 @@ export function AiChat({
740
856
  const [pendingFiles, setPendingFiles] = React.useState<PendingAttachment[]>([])
741
857
  const upload = useAiChatUpload()
742
858
  const isUploading = upload.busy
859
+ const footerRef = React.useRef<HTMLDivElement | null>(null)
860
+ const [isCompactFooter, setIsCompactFooter] = React.useState(defaultCompactFooter)
861
+
862
+ React.useEffect(() => {
863
+ const element = footerRef.current
864
+ if (!element || typeof ResizeObserver === 'undefined') return
865
+
866
+ const updateCompactState = (width: number) => {
867
+ setIsCompactFooter(width < COMPACT_FOOTER_MAX_WIDTH)
868
+ }
869
+
870
+ updateCompactState(element.getBoundingClientRect().width)
871
+ const observer = new ResizeObserver((entries) => {
872
+ const entry = entries[0]
873
+ if (!entry) return
874
+ updateCompactState(entry.contentRect.width)
875
+ })
876
+ observer.observe(element)
877
+ return () => observer.disconnect()
878
+ }, [])
743
879
 
744
880
  const uploadedAttachmentIds = React.useMemo(
745
881
  () =>
@@ -754,6 +890,66 @@ export function AiChat({
754
890
  [attachmentIds, uploadedAttachmentIds],
755
891
  )
756
892
 
893
+ const {
894
+ providers: modelProviders,
895
+ allowRuntimeModelOverride,
896
+ defaultLabel: modelDefaultLabel,
897
+ loaded: modelProvidersLoaded,
898
+ } = useAgentModels(agent)
899
+
900
+ const [modelPickerValue, setModelPickerValue] = React.useState<ModelPickerValue | null>(() =>
901
+ readModelPickerValue(agent),
902
+ )
903
+
904
+ const effectiveModelPickerValue = React.useMemo(() => {
905
+ if (!modelProvidersLoaded || !allowRuntimeModelOverride || modelProviders.length === 0) {
906
+ return null
907
+ }
908
+ if (modelPickerValue && isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
909
+ return modelPickerValue
910
+ }
911
+ return firstAvailableModelPickerValue(modelProviders)
912
+ }, [
913
+ allowRuntimeModelOverride,
914
+ modelPickerValue,
915
+ modelProviders,
916
+ modelProvidersLoaded,
917
+ ])
918
+
919
+ React.useEffect(() => {
920
+ setModelPickerValue(readModelPickerValue(agent))
921
+ }, [agent])
922
+
923
+ React.useEffect(() => {
924
+ if (!modelProvidersLoaded) return
925
+ if (!allowRuntimeModelOverride || modelProviders.length === 0) {
926
+ if (modelPickerValue !== null) {
927
+ setModelPickerValue(null)
928
+ writeModelPickerValue(agent, null)
929
+ }
930
+ return
931
+ }
932
+ if (modelPickerValue && !isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
933
+ const fallback = firstAvailableModelPickerValue(modelProviders)
934
+ setModelPickerValue(fallback)
935
+ writeModelPickerValue(agent, fallback)
936
+ }
937
+ }, [
938
+ agent,
939
+ allowRuntimeModelOverride,
940
+ modelPickerValue,
941
+ modelProviders,
942
+ modelProvidersLoaded,
943
+ ])
944
+
945
+ const handleModelPickerChange = React.useCallback(
946
+ (value: ModelPickerValue | null) => {
947
+ setModelPickerValue(value)
948
+ writeModelPickerValue(agent, value)
949
+ },
950
+ [agent],
951
+ )
952
+
757
953
  const chat = useAiChat({
758
954
  agent,
759
955
  apiPath,
@@ -763,6 +959,8 @@ export function AiChat({
763
959
  initialMessages,
764
960
  onError,
765
961
  conversationId,
962
+ providerOverride: effectiveModelPickerValue?.providerId ?? null,
963
+ modelOverride: effectiveModelPickerValue?.modelId ?? null,
766
964
  })
767
965
 
768
966
  const isStreaming = chat.status === 'streaming'
@@ -1065,15 +1263,15 @@ export function AiChat({
1065
1263
  return (
1066
1264
  <section
1067
1265
  className={cn(
1068
- 'flex h-full min-h-[320px] flex-col gap-3 rounded-lg border border-border bg-background p-3',
1266
+ 'flex h-full min-h-[320px] min-w-0 flex-col gap-3 overflow-hidden rounded-lg border border-border bg-background p-3',
1069
1267
  className,
1070
1268
  )}
1071
1269
  aria-label={t('ai_assistant.chat.regionLabel', 'AI chat')}
1072
1270
  data-ai-chat-agent={agent}
1073
1271
  data-ai-chat-conversation-id={chat.conversationId}
1074
1272
  >
1075
- <div className="flex items-center justify-between gap-2 border-b border-border pb-2">
1076
- <div className="flex flex-1 items-center gap-2 text-xs text-muted-foreground">
1273
+ <div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pb-2">
1274
+ <div className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground">
1077
1275
  {contextItems && contextItems.length > 0 ? (
1078
1276
  <ContextItemsPill items={contextItems} />
1079
1277
  ) : (
@@ -1100,7 +1298,7 @@ export function AiChat({
1100
1298
  role="log"
1101
1299
  aria-live="polite"
1102
1300
  aria-label={t('ai_assistant.chat.transcriptLabel', 'Chat transcript')}
1103
- className="flex-1 space-y-2 overflow-y-auto pr-1"
1301
+ className="min-w-0 flex-1 space-y-2 overflow-y-auto overflow-x-hidden pr-1"
1104
1302
  >
1105
1303
  {chat.messages.length === 0 ? (
1106
1304
  <WelcomeState
@@ -1153,7 +1351,7 @@ export function AiChat({
1153
1351
  ) : null}
1154
1352
 
1155
1353
  <form
1156
- className="flex flex-col gap-2"
1354
+ className="flex min-w-0 flex-col gap-2"
1157
1355
  onSubmit={(event) => {
1158
1356
  event.preventDefault()
1159
1357
  handleSubmit()
@@ -1203,7 +1401,7 @@ export function AiChat({
1203
1401
  onKeyDown={handleKeyDown}
1204
1402
  rows={3}
1205
1403
  aria-label={t('ai_assistant.chat.composerLabel', 'Message composer')}
1206
- className="resize-none"
1404
+ className="min-w-0 resize-none"
1207
1405
  />
1208
1406
  <input
1209
1407
  ref={fileInputRef}
@@ -1214,8 +1412,13 @@ export function AiChat({
1214
1412
  onChange={handleFileSelect}
1215
1413
  data-ai-chat-file-input=""
1216
1414
  />
1217
- <div className="flex items-center justify-between gap-2">
1218
- <div className="flex items-center gap-2">
1415
+ <div
1416
+ ref={footerRef}
1417
+ className="flex min-w-0 items-center justify-between gap-2"
1418
+ data-ai-chat-footer=""
1419
+ data-ai-chat-footer-compact={isCompactFooter ? 'true' : 'false'}
1420
+ >
1421
+ <div className="flex min-w-0 items-center gap-2">
1219
1422
  <IconButton
1220
1423
  type="button"
1221
1424
  variant="ghost"
@@ -1226,7 +1429,19 @@ export function AiChat({
1226
1429
  >
1227
1430
  <Paperclip className="size-4" aria-hidden />
1228
1431
  </IconButton>
1229
- <p className="text-xs text-muted-foreground">
1432
+ {allowRuntimeModelOverride && modelProviders.length > 0 ? (
1433
+ <ModelPicker
1434
+ agentId={agent}
1435
+ value={effectiveModelPickerValue}
1436
+ onChange={handleModelPickerChange}
1437
+ availableProviders={modelProviders}
1438
+ disabled={isBusy}
1439
+ compact={isCompactFooter}
1440
+ defaultLabel={modelDefaultLabel}
1441
+ className="shrink-0"
1442
+ />
1443
+ ) : null}
1444
+ <p className={cn('text-xs text-muted-foreground', isCompactFooter && 'hidden')}>
1230
1445
  {hasUploadingFiles || isUploading
1231
1446
  ? t(
1232
1447
  'ai_assistant.chat.uploadingHint',
@@ -1238,7 +1453,7 @@ export function AiChat({
1238
1453
  )}
1239
1454
  </p>
1240
1455
  </div>
1241
- <div className="flex items-center gap-2">
1456
+ <div className="flex shrink-0 items-center gap-2">
1242
1457
  {isStreaming ? (
1243
1458
  <IconButton
1244
1459
  type="button"
@@ -1269,9 +1484,12 @@ export function AiChat({
1269
1484
  ? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
1270
1485
  : undefined
1271
1486
  }
1487
+ className={cn(isCompactFooter && 'w-8 px-0')}
1272
1488
  >
1273
1489
  <Send className="size-4" aria-hidden />
1274
- <span>{t('ai_assistant.chat.send', 'Send message')}</span>
1490
+ {!isCompactFooter ? (
1491
+ <span>{t('ai_assistant.chat.send', 'Send message')}</span>
1492
+ ) : null}
1275
1493
  </Button>
1276
1494
  </div>
1277
1495
  </div>
package/src/ai/AiDock.tsx CHANGED
@@ -381,7 +381,7 @@ function AiDockPanel({
381
381
  // primary surface (full-screen sheet) and a fixed side panel would
382
382
  // crowd the viewport.
383
383
  'hidden lg:flex',
384
- 'fixed top-0 right-0 z-overlay h-svh flex-col border-l bg-background shadow-lg',
384
+ 'fixed top-0 right-0 z-overlay h-svh min-w-0 flex-col overflow-hidden border-l bg-background shadow-lg',
385
385
  collapsed ? 'w-12' : '',
386
386
  )}
387
387
  style={collapsed ? undefined : { width }}
@@ -478,7 +478,7 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
478
478
  return (
479
479
  <>
480
480
  <ChatPaneTabs agentId={assistant.agent} className="border-b" />
481
- <div className="min-h-0 flex-1" data-ai-dock-chat-container="">
481
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden" data-ai-dock-chat-container="">
482
482
  {session ? (
483
483
  <React.Suspense fallback={null}>
484
484
  <LazyAiChat
@@ -491,6 +491,7 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
491
491
  conversationId={session.conversationId}
492
492
  pageContext={assistant.pageContext}
493
493
  className="h-full"
494
+ defaultCompactFooter
494
495
  placeholder={assistant.placeholder}
495
496
  suggestions={assistant.suggestions}
496
497
  contextItems={assistant.contextItems}
@@ -0,0 +1,248 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ChevronDown, Cpu } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+ import { Button } from '../primitives/button'
8
+
9
+ export interface ModelPickerProviderModel {
10
+ id: string
11
+ name: string
12
+ contextWindow?: number | null
13
+ tags?: string[]
14
+ isDefault: boolean
15
+ }
16
+
17
+ export interface ModelPickerProvider {
18
+ id: string
19
+ name: string
20
+ isDefault: boolean
21
+ models: ModelPickerProviderModel[]
22
+ }
23
+
24
+ export interface ModelPickerValue {
25
+ providerId: string
26
+ modelId: string
27
+ baseURL?: string | null
28
+ }
29
+
30
+ export interface ModelPickerProps {
31
+ agentId: string
32
+ value: ModelPickerValue | null
33
+ onChange: (value: ModelPickerValue | null) => void
34
+ availableProviders: ModelPickerProvider[]
35
+ disabled?: boolean
36
+ compact?: boolean
37
+ defaultLabel?: string | null
38
+ className?: string
39
+ }
40
+
41
+ /**
42
+ * Stateless dropdown for selecting a provider + model override in the chat UI.
43
+ *
44
+ * - Renders provider name + model id + "(default)" badge for the agent's resolved default.
45
+ * - Keyboard-accessible: trigger opens a floating list, arrow keys navigate, Enter selects,
46
+ * Escape closes.
47
+ * - Does NOT expose a baseURL input (R6 mitigation: only provider+model selection from the
48
+ * curated catalog is exposed to end users).
49
+ * - localStorage persistence keyed by agentId is handled by the consumer (AiChat / 4b.2).
50
+ * - When allowRuntimeModelOverride is false the consumer should not render this component
51
+ * at all; this component itself does no gating.
52
+ */
53
+ export function ModelPicker({
54
+ agentId,
55
+ value,
56
+ onChange,
57
+ availableProviders,
58
+ disabled,
59
+ compact = false,
60
+ defaultLabel,
61
+ className,
62
+ }: ModelPickerProps) {
63
+ const t = useT()
64
+ const [open, setOpen] = React.useState(false)
65
+ const containerRef = React.useRef<HTMLDivElement>(null)
66
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
67
+
68
+ const selectedLabel = React.useMemo(() => {
69
+ if (!value) {
70
+ return defaultLabel
71
+ ? t('ai_assistant.modelPicker.defaultWithModelLabel', 'Default: {{model}}').replace('{{model}}', defaultLabel)
72
+ : t('ai_assistant.modelPicker.defaultLabel', 'Model: Default')
73
+ }
74
+ const provider = availableProviders.find((p) => p.id === value.providerId)
75
+ const model = provider?.models.find((m) => m.id === value.modelId)
76
+ const modelLabel = model?.name ?? value.modelId
77
+ const providerLabel = provider?.name ?? value.providerId
78
+ return `${providerLabel} / ${modelLabel}`
79
+ }, [value, availableProviders, defaultLabel, t])
80
+
81
+ const handleToggle = React.useCallback(() => {
82
+ if (disabled) return
83
+ setOpen((prev) => !prev)
84
+ }, [disabled])
85
+
86
+ const handleSelect = React.useCallback(
87
+ (providerId: string, modelId: string) => {
88
+ onChange({ providerId, modelId })
89
+ setOpen(false)
90
+ triggerRef.current?.focus()
91
+ },
92
+ [onChange],
93
+ )
94
+
95
+ const handleClearDefault = React.useCallback(() => {
96
+ onChange(null)
97
+ setOpen(false)
98
+ triggerRef.current?.focus()
99
+ }, [onChange])
100
+
101
+ React.useEffect(() => {
102
+ if (!open) return
103
+ const handleKeyDown = (event: KeyboardEvent) => {
104
+ if (event.key === 'Escape') {
105
+ setOpen(false)
106
+ triggerRef.current?.focus()
107
+ }
108
+ }
109
+ document.addEventListener('keydown', handleKeyDown)
110
+ return () => document.removeEventListener('keydown', handleKeyDown)
111
+ }, [open])
112
+
113
+ React.useEffect(() => {
114
+ if (!open) return
115
+ const handleClickOutside = (event: MouseEvent) => {
116
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
117
+ setOpen(false)
118
+ }
119
+ }
120
+ document.addEventListener('mousedown', handleClickOutside)
121
+ return () => document.removeEventListener('mousedown', handleClickOutside)
122
+ }, [open])
123
+
124
+ if (availableProviders.length === 0) return null
125
+
126
+ return (
127
+ <div ref={containerRef} className={cn('relative', className)} data-ai-model-picker={agentId}>
128
+ <Button
129
+ ref={triggerRef}
130
+ type="button"
131
+ variant="outline"
132
+ size="sm"
133
+ disabled={disabled}
134
+ onClick={handleToggle}
135
+ aria-haspopup="listbox"
136
+ aria-expanded={open}
137
+ aria-label={t('ai_assistant.modelPicker.triggerAriaLabel', 'Select AI model')}
138
+ title={selectedLabel}
139
+ data-ai-model-picker-trigger
140
+ className={cn(
141
+ 'font-normal',
142
+ compact ? 'w-8 gap-0 px-0' : 'gap-1.5 px-2.5',
143
+ )}
144
+ >
145
+ <Cpu className="size-3.5 shrink-0" aria-hidden />
146
+ {!compact ? (
147
+ <span className="max-w-36 truncate text-xs">{selectedLabel}</span>
148
+ ) : null}
149
+ <ChevronDown
150
+ className={cn(
151
+ 'size-3 shrink-0 transition-transform',
152
+ compact && 'hidden',
153
+ open && 'rotate-180',
154
+ )}
155
+ aria-hidden
156
+ />
157
+ </Button>
158
+
159
+ {open ? (
160
+ <div
161
+ role="listbox"
162
+ aria-label={t('ai_assistant.modelPicker.listAriaLabel', 'Available models')}
163
+ className={cn(
164
+ 'absolute bottom-full left-0 z-50 mb-1 max-h-72 min-w-56 overflow-y-auto',
165
+ 'rounded-md border border-border bg-popover shadow-md',
166
+ )}
167
+ data-ai-model-picker-dropdown
168
+ >
169
+ <div
170
+ role="option"
171
+ aria-selected={value === null}
172
+ tabIndex={0}
173
+ className={cn(
174
+ 'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
175
+ 'flex items-center gap-2',
176
+ value === null && 'bg-accent/50',
177
+ )}
178
+ onClick={handleClearDefault}
179
+ onKeyDown={(event) => {
180
+ if (event.key === 'Enter' || event.key === ' ') {
181
+ event.preventDefault()
182
+ handleClearDefault()
183
+ }
184
+ }}
185
+ data-ai-model-picker-default-option
186
+ >
187
+ <span className="flex-1">
188
+ {defaultLabel
189
+ ? t('ai_assistant.modelPicker.useDefaultWithModel', 'Use agent default: {{model}}').replace('{{model}}', defaultLabel)
190
+ : t('ai_assistant.modelPicker.useDefault', 'Use agent default')}
191
+ </span>
192
+ {value === null ? (
193
+ <span className="rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
194
+ {t('ai_assistant.modelPicker.activeBadge', 'active')}
195
+ </span>
196
+ ) : null}
197
+ </div>
198
+ {availableProviders.map((provider) => (
199
+ <div key={provider.id} data-ai-model-picker-provider={provider.id}>
200
+ <div className="px-3 py-1.5 text-overline font-semibold uppercase tracking-wider text-muted-foreground">
201
+ {provider.name}
202
+ </div>
203
+ {provider.models.map((model) => {
204
+ const isSelected =
205
+ value?.providerId === provider.id && value?.modelId === model.id
206
+ return (
207
+ <div
208
+ key={model.id}
209
+ role="option"
210
+ aria-selected={isSelected}
211
+ tabIndex={0}
212
+ className={cn(
213
+ 'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
214
+ 'flex items-center gap-2 pl-5',
215
+ isSelected && 'bg-accent/50',
216
+ )}
217
+ onClick={() => handleSelect(provider.id, model.id)}
218
+ onKeyDown={(event) => {
219
+ if (event.key === 'Enter' || event.key === ' ') {
220
+ event.preventDefault()
221
+ handleSelect(provider.id, model.id)
222
+ }
223
+ }}
224
+ data-ai-model-picker-model={`${provider.id}:${model.id}`}
225
+ >
226
+ <span className="flex-1 truncate">{model.name}</span>
227
+ {model.isDefault ? (
228
+ <span className="shrink-0 rounded bg-muted px-1 py-0.5 text-overline text-muted-foreground">
229
+ {t('ai_assistant.modelPicker.defaultBadge', 'default')}
230
+ </span>
231
+ ) : null}
232
+ {isSelected ? (
233
+ <span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
234
+ {t('ai_assistant.modelPicker.activeBadge', 'active')}
235
+ </span>
236
+ ) : null}
237
+ </div>
238
+ )
239
+ })}
240
+ </div>
241
+ ))}
242
+ </div>
243
+ ) : null}
244
+ </div>
245
+ )
246
+ }
247
+
248
+ export default ModelPicker