@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,505 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Global AI Dock — a persistent right-side panel that hosts an `<AiChat>`
5
+ * surface across page navigations.
6
+ *
7
+ * Modules invoke `useAiDock().dock({ agent, label, pageContext })` (typically
8
+ * from the dialog header of an injection trigger) to move an active
9
+ * assistant from a transient dialog into the dock. The dock survives router
10
+ * navigation because the provider is mounted at the layout root (AppShell).
11
+ *
12
+ * The panel renders only when something is docked. The docked assistant, its
13
+ * collapsed state, and its width are persisted in localStorage so refreshing
14
+ * the page restores the same open dock.
15
+ */
16
+
17
+ import * as React from 'react'
18
+ import { Maximize2, Minimize2, X } from 'lucide-react'
19
+ import type { AiChatContextItem, AiChatSuggestion } from './AiChat'
20
+ import { ChatPaneTabs } from './ChatPaneTabs'
21
+ import { useAiChatSessions } from './AiChatSessions'
22
+ import { IconButton } from '../primitives/icon-button'
23
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
24
+ import { cn } from '@open-mercato/shared/lib/utils'
25
+
26
+ // Lazy import keeps the heavy chat surface (AI SDK + streaming runtime) out
27
+ // of the AppShell import graph. The dock provider only renders the chat when
28
+ // something is actually docked, so tests that never dock skip the import.
29
+ const LazyAiChat = React.lazy(async () => {
30
+ const mod = await import('./AiChat')
31
+ return { default: mod.AiChat }
32
+ })
33
+
34
+ const STORAGE_KEY = 'om-ai-dock-v1'
35
+ const MIN_WIDTH = 320
36
+ const MAX_WIDTH = 960
37
+ const DEFAULT_WIDTH = 420
38
+
39
+ export interface AiDockedAssistant {
40
+ /** AI agent id (must be enabled for the current user). */
41
+ agent: string
42
+ /** Human-readable label shown in the dock header. */
43
+ label: string
44
+ /** Optional secondary description (e.g. module name). */
45
+ description?: string
46
+ /** Spec §10.1 page-context payload sent with each turn. */
47
+ pageContext?: Record<string, unknown>
48
+ /** Composer placeholder copy. */
49
+ placeholder?: string
50
+ /** Welcome card title. */
51
+ welcomeTitle?: string
52
+ /** Welcome card description. */
53
+ welcomeDescription?: string
54
+ /** Optional starter suggestions. */
55
+ suggestions?: AiChatSuggestion[]
56
+ /** Optional pinned context chips (e.g. "3 selected"). */
57
+ contextItems?: AiChatContextItem[]
58
+ }
59
+
60
+ interface AiDockState {
61
+ assistant: AiDockedAssistant | null
62
+ width: number
63
+ collapsed: boolean
64
+ }
65
+
66
+ interface PersistedAiChatSuggestion {
67
+ label: string
68
+ prompt: string
69
+ }
70
+
71
+ interface PersistedAiDockedAssistant {
72
+ agent: string
73
+ label: string
74
+ description?: string
75
+ pageContext?: Record<string, unknown>
76
+ placeholder?: string
77
+ welcomeTitle?: string
78
+ welcomeDescription?: string
79
+ suggestions?: PersistedAiChatSuggestion[]
80
+ contextItems?: AiChatContextItem[]
81
+ }
82
+
83
+ interface PersistedAiDockState {
84
+ assistant?: PersistedAiDockedAssistant | null
85
+ width?: number
86
+ collapsed?: boolean
87
+ }
88
+
89
+ interface AiDockApi {
90
+ state: AiDockState
91
+ /** Open / replace the docked assistant. */
92
+ dock: (assistant: AiDockedAssistant) => void
93
+ /** Close the dock. */
94
+ undock: () => void
95
+ /** True when the docked agent matches `agentId`. */
96
+ isDocked: (agentId: string) => boolean
97
+ }
98
+
99
+ const COLLAPSED_WIDTH = 48
100
+
101
+ const AiDockContext = React.createContext<AiDockApi | null>(null)
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
105
+ }
106
+
107
+ function readOptionalString(value: unknown): string | undefined {
108
+ return typeof value === 'string' && value.length > 0 ? value : undefined
109
+ }
110
+
111
+ function readWidth(value: unknown): number {
112
+ return Math.min(
113
+ MAX_WIDTH,
114
+ Math.max(MIN_WIDTH, typeof value === 'number' ? value : DEFAULT_WIDTH),
115
+ )
116
+ }
117
+
118
+ function readPersistedSuggestions(value: unknown): PersistedAiChatSuggestion[] | undefined {
119
+ if (!Array.isArray(value)) return undefined
120
+ const suggestions = value
121
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
122
+ .map((entry) => {
123
+ const label = readOptionalString(entry.label)
124
+ const prompt = readOptionalString(entry.prompt)
125
+ if (!label || !prompt) return null
126
+ return { label, prompt }
127
+ })
128
+ .filter((entry): entry is PersistedAiChatSuggestion => entry !== null)
129
+ return suggestions.length > 0 ? suggestions : undefined
130
+ }
131
+
132
+ function readPersistedContextItems(value: unknown): AiChatContextItem[] | undefined {
133
+ if (!Array.isArray(value)) return undefined
134
+ const contextItems = value
135
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
136
+ .map((entry) => {
137
+ const label = readOptionalString(entry.label)
138
+ if (!label) return null
139
+ const detail = readOptionalString(entry.detail)
140
+ return detail ? { label, detail } : { label }
141
+ })
142
+ .filter((entry): entry is AiChatContextItem => entry !== null)
143
+ return contextItems.length > 0 ? contextItems : undefined
144
+ }
145
+
146
+ function readPersistedAssistant(value: unknown): AiDockedAssistant | null {
147
+ if (!isRecord(value)) return null
148
+ const agent = readOptionalString(value.agent)
149
+ const label = readOptionalString(value.label)
150
+ if (!agent || !label) return null
151
+
152
+ return {
153
+ agent,
154
+ label,
155
+ description: readOptionalString(value.description),
156
+ pageContext: isRecord(value.pageContext) ? value.pageContext : undefined,
157
+ placeholder: readOptionalString(value.placeholder),
158
+ welcomeTitle: readOptionalString(value.welcomeTitle),
159
+ welcomeDescription: readOptionalString(value.welcomeDescription),
160
+ suggestions: readPersistedSuggestions(value.suggestions),
161
+ contextItems: readPersistedContextItems(value.contextItems),
162
+ }
163
+ }
164
+
165
+ function serializeAssistant(assistant: AiDockedAssistant | null): PersistedAiDockedAssistant | null {
166
+ if (!assistant) return null
167
+ return {
168
+ agent: assistant.agent,
169
+ label: assistant.label,
170
+ description: assistant.description,
171
+ pageContext: assistant.pageContext,
172
+ placeholder: assistant.placeholder,
173
+ welcomeTitle: assistant.welcomeTitle,
174
+ welcomeDescription: assistant.welcomeDescription,
175
+ suggestions: assistant.suggestions?.map((suggestion) => ({
176
+ label: suggestion.label,
177
+ prompt: suggestion.prompt,
178
+ })),
179
+ contextItems: assistant.contextItems?.map((item) => ({
180
+ label: item.label,
181
+ detail: item.detail,
182
+ })),
183
+ }
184
+ }
185
+
186
+ function readPersisted(): AiDockState {
187
+ if (typeof window === 'undefined') return { assistant: null, width: DEFAULT_WIDTH, collapsed: false }
188
+ try {
189
+ const raw = window.localStorage.getItem(STORAGE_KEY)
190
+ if (!raw) return { assistant: null, width: DEFAULT_WIDTH, collapsed: false }
191
+ const parsed = JSON.parse(raw) as PersistedAiDockState | null
192
+ return {
193
+ assistant: readPersistedAssistant(parsed?.assistant),
194
+ width: readWidth(parsed?.width),
195
+ collapsed: parsed?.collapsed === true,
196
+ }
197
+ } catch {
198
+ return { assistant: null, width: DEFAULT_WIDTH, collapsed: false }
199
+ }
200
+ }
201
+
202
+ function writePersisted(state: AiDockState) {
203
+ if (typeof window === 'undefined') return
204
+ try {
205
+ window.localStorage.setItem(
206
+ STORAGE_KEY,
207
+ JSON.stringify({
208
+ assistant: serializeAssistant(state.assistant),
209
+ width: state.width,
210
+ collapsed: state.collapsed,
211
+ } satisfies PersistedAiDockState),
212
+ )
213
+ } catch {
214
+ /* ignore */
215
+ }
216
+ }
217
+
218
+ export function AiDockProvider({ children }: { children: React.ReactNode }) {
219
+ const [state, setState] = React.useState<AiDockState>(() => ({
220
+ assistant: null,
221
+ width: DEFAULT_WIDTH,
222
+ collapsed: false,
223
+ }))
224
+ const [hydrated, setHydrated] = React.useState(false)
225
+
226
+ React.useEffect(() => {
227
+ const persisted = readPersisted()
228
+ setState(persisted)
229
+ setHydrated(true)
230
+ }, [])
231
+
232
+ React.useEffect(() => {
233
+ if (!hydrated) return
234
+ writePersisted(state)
235
+ }, [hydrated, state])
236
+
237
+ const dock = React.useCallback((assistant: AiDockedAssistant) => {
238
+ // Always reset `collapsed` when (re)docking — the operator just clicked
239
+ // "dock to side" and expects the panel to be visible at full width.
240
+ setState((prev) => ({ ...prev, assistant, collapsed: false }))
241
+ }, [])
242
+
243
+ const undock = React.useCallback(() => {
244
+ // Hard reset: drop the assistant AND the collapsed flag so the next
245
+ // dock call starts from a clean slate. The wrapper observes `assistant`
246
+ // turning null and clears its layout shift in the same render tick.
247
+ setState((prev) => ({ ...prev, assistant: null, collapsed: false }))
248
+ }, [])
249
+
250
+ const isDocked = React.useCallback(
251
+ (agentId: string) => state.assistant?.agent === agentId,
252
+ [state.assistant?.agent],
253
+ )
254
+
255
+ const setWidth = React.useCallback((width: number) => {
256
+ setState((prev) => ({ ...prev, width }))
257
+ }, [])
258
+
259
+ const setCollapsed = React.useCallback((collapsed: boolean) => {
260
+ setState((prev) => ({ ...prev, collapsed }))
261
+ }, [])
262
+
263
+ const api = React.useMemo<AiDockApi>(
264
+ () => ({ state, dock, undock, isDocked }),
265
+ [state, dock, undock, isDocked],
266
+ )
267
+
268
+ // The right-side padding the page must reserve for the dock panel. Stays
269
+ // null while nothing is docked so the wrapper renders no className/style
270
+ // and the underlying layout (DataTable, sidebar grid) reclaims its full
271
+ // width on undock.
272
+ const reservedWidth = state.assistant
273
+ ? state.collapsed
274
+ ? COLLAPSED_WIDTH
275
+ : state.width
276
+ : null
277
+
278
+ return (
279
+ <AiDockContext.Provider value={api}>
280
+ <div
281
+ // The dock is desktop-only (`lg+`); reserve right-side padding only
282
+ // at that breakpoint so mobile layout stays full-width.
283
+ className={reservedWidth != null ? 'lg:pr-[var(--om-ai-dock-width)]' : undefined}
284
+ style={
285
+ reservedWidth != null
286
+ ? ({ ['--om-ai-dock-width' as string]: `${reservedWidth}px` } as React.CSSProperties)
287
+ : undefined
288
+ }
289
+ >
290
+ {children}
291
+ </div>
292
+ {state.assistant ? (
293
+ <AiDockPanel
294
+ assistant={state.assistant}
295
+ width={state.width}
296
+ collapsed={state.collapsed}
297
+ onCollapsedChange={setCollapsed}
298
+ onWidthChange={setWidth}
299
+ onClose={undock}
300
+ />
301
+ ) : null}
302
+ </AiDockContext.Provider>
303
+ )
304
+ }
305
+
306
+ export function useAiDock(): AiDockApi {
307
+ const ctx = React.useContext(AiDockContext)
308
+ if (ctx) return ctx
309
+ // Fallback no-op API — keeps consumers safe when the provider is absent
310
+ // (e.g. unit tests rendering a widget in isolation).
311
+ return {
312
+ state: { assistant: null, width: DEFAULT_WIDTH, collapsed: false },
313
+ dock: () => {},
314
+ undock: () => {},
315
+ isDocked: () => false,
316
+ }
317
+ }
318
+
319
+ interface AiDockPanelProps {
320
+ assistant: AiDockedAssistant
321
+ width: number
322
+ collapsed: boolean
323
+ onCollapsedChange: (collapsed: boolean) => void
324
+ onWidthChange: (width: number) => void
325
+ onClose: () => void
326
+ }
327
+
328
+ function AiDockPanel({
329
+ assistant,
330
+ width,
331
+ collapsed,
332
+ onCollapsedChange,
333
+ onWidthChange,
334
+ onClose,
335
+ }: AiDockPanelProps) {
336
+ const t = useT()
337
+ const dragStateRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
338
+
339
+ const handlePointerDown = React.useCallback(
340
+ (event: React.PointerEvent<HTMLDivElement>) => {
341
+ event.preventDefault()
342
+ const target = event.currentTarget
343
+ target.setPointerCapture(event.pointerId)
344
+ dragStateRef.current = { startX: event.clientX, startWidth: width }
345
+ },
346
+ [width],
347
+ )
348
+
349
+ const handlePointerMove = React.useCallback(
350
+ (event: React.PointerEvent<HTMLDivElement>) => {
351
+ if (!dragStateRef.current) return
352
+ const delta = dragStateRef.current.startX - event.clientX
353
+ const next = Math.min(
354
+ MAX_WIDTH,
355
+ Math.max(MIN_WIDTH, dragStateRef.current.startWidth + delta),
356
+ )
357
+ onWidthChange(next)
358
+ },
359
+ [onWidthChange],
360
+ )
361
+
362
+ const handlePointerUp = React.useCallback(
363
+ (event: React.PointerEvent<HTMLDivElement>) => {
364
+ const target = event.currentTarget
365
+ try {
366
+ target.releasePointerCapture(event.pointerId)
367
+ } catch {
368
+ /* ignore */
369
+ }
370
+ dragStateRef.current = null
371
+ },
372
+ [],
373
+ )
374
+
375
+ return (
376
+ <aside
377
+ data-ai-dock-panel=""
378
+ data-ai-dock-agent={assistant.agent}
379
+ className={cn(
380
+ // Dock is desktop-only — on small screens the AiChat dialog is the
381
+ // primary surface (full-screen sheet) and a fixed side panel would
382
+ // crowd the viewport.
383
+ 'hidden lg:flex',
384
+ 'fixed top-0 right-0 z-overlay h-svh flex-col border-l bg-background shadow-lg',
385
+ collapsed ? 'w-12' : '',
386
+ )}
387
+ style={collapsed ? undefined : { width }}
388
+ aria-label={assistant.label}
389
+ >
390
+ {!collapsed ? (
391
+ <div
392
+ role="separator"
393
+ aria-orientation="vertical"
394
+ tabIndex={-1}
395
+ onPointerDown={handlePointerDown}
396
+ onPointerMove={handlePointerMove}
397
+ onPointerUp={handlePointerUp}
398
+ onPointerCancel={handlePointerUp}
399
+ className="absolute left-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/20"
400
+ data-ai-dock-resize-handle=""
401
+ />
402
+ ) : null}
403
+ <header className="flex items-center gap-2 px-3 py-2">
404
+ {collapsed ? (
405
+ <IconButton
406
+ type="button"
407
+ variant="ghost"
408
+ size="sm"
409
+ aria-label={t('ai_assistant.chat.dock.expand', 'Expand AI dock')}
410
+ title={t('ai_assistant.chat.dock.expand', 'Expand AI dock')}
411
+ onClick={() => onCollapsedChange(false)}
412
+ >
413
+ <Maximize2 className="size-4" />
414
+ </IconButton>
415
+ ) : (
416
+ <>
417
+ <div className="min-w-0 flex-1">
418
+ <div className="flex items-center gap-2 text-sm font-medium" data-ai-dock-label="">
419
+ <span className="truncate">{assistant.label}</span>
420
+ <span
421
+ className="inline-flex shrink-0 items-center rounded-full border border-border bg-secondary px-1.5 py-0 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground"
422
+ data-ai-beta-chip=""
423
+ >
424
+ {t('ai_assistant.chat.betaChip', 'beta')}
425
+ </span>
426
+ </div>
427
+ {assistant.description ? (
428
+ <div className="truncate text-xs text-muted-foreground">
429
+ {assistant.description}
430
+ </div>
431
+ ) : null}
432
+ </div>
433
+ <IconButton
434
+ type="button"
435
+ variant="ghost"
436
+ size="sm"
437
+ aria-label={t('ai_assistant.chat.dock.collapse', 'Collapse AI dock')}
438
+ title={t('ai_assistant.chat.dock.collapse', 'Collapse AI dock')}
439
+ onClick={() => onCollapsedChange(true)}
440
+ >
441
+ <Minimize2 className="size-4" />
442
+ </IconButton>
443
+ <IconButton
444
+ type="button"
445
+ variant="ghost"
446
+ size="sm"
447
+ aria-label={t('ai_assistant.chat.dock.close', 'Close AI dock')}
448
+ title={t('ai_assistant.chat.dock.close', 'Close AI dock')}
449
+ onClick={onClose}
450
+ data-ai-dock-close=""
451
+ >
452
+ <X className="size-4" />
453
+ </IconButton>
454
+ </>
455
+ )}
456
+ </header>
457
+ {!collapsed ? (
458
+ <DockedChatBody assistant={assistant} />
459
+ ) : null}
460
+ </aside>
461
+ )
462
+ }
463
+
464
+ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
465
+ const sessions = useAiChatSessions()
466
+ const session = sessions.getActiveSession(assistant.agent)
467
+
468
+ // Lazily ensure an open session exists. Running `ensureSession` inside an
469
+ // effect (not inline during render) keeps the provider's setState calls
470
+ // outside of the render phase. The first frame may render without a
471
+ // session — that's fine, we render the tab strip alone until the next
472
+ // tick when the new session is committed and `getActiveSession` returns
473
+ // it.
474
+ React.useEffect(() => {
475
+ if (!session) sessions.ensureSession(assistant.agent)
476
+ }, [assistant.agent, session, sessions])
477
+
478
+ return (
479
+ <>
480
+ <ChatPaneTabs agentId={assistant.agent} className="border-b" />
481
+ <div className="min-h-0 flex-1" data-ai-dock-chat-container="">
482
+ {session ? (
483
+ <React.Suspense fallback={null}>
484
+ <LazyAiChat
485
+ // `key` forces a fresh `<AiChat>` mount whenever the active
486
+ // session changes — without it the AI SDK's internal status
487
+ // would carry across tabs and surface the previous tab's
488
+ // streaming indicator on a brand-new conversation.
489
+ key={session.id}
490
+ agent={assistant.agent}
491
+ conversationId={session.conversationId}
492
+ pageContext={assistant.pageContext}
493
+ className="h-full"
494
+ placeholder={assistant.placeholder}
495
+ suggestions={assistant.suggestions}
496
+ contextItems={assistant.contextItems}
497
+ welcomeTitle={assistant.welcomeTitle}
498
+ welcomeDescription={assistant.welcomeDescription}
499
+ />
500
+ </React.Suspense>
501
+ ) : null}
502
+ </div>
503
+ </>
504
+ )
505
+ }