@open-mercato/ui 0.6.3-develop.3894.1.352abf4240 → 0.6.3

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 (67) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/ai/AiAssistantLauncher.js +61 -8
  3. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  4. package/dist/ai/AiChat.js +29 -6
  5. package/dist/ai/AiChat.js.map +2 -2
  6. package/dist/ai/AiChatSessions.js +31 -9
  7. package/dist/ai/AiChatSessions.js.map +2 -2
  8. package/dist/ai/AiDock.js +8 -1
  9. package/dist/ai/AiDock.js.map +2 -2
  10. package/dist/ai/ConversationShareButton.js +40 -0
  11. package/dist/ai/ConversationShareButton.js.map +7 -0
  12. package/dist/ai/ConversationShareDialog.js +234 -0
  13. package/dist/ai/ConversationShareDialog.js.map +7 -0
  14. package/dist/ai/conversation-store.js +4 -2
  15. package/dist/ai/conversation-store.js.map +2 -2
  16. package/dist/ai/index.js +4 -0
  17. package/dist/ai/index.js.map +2 -2
  18. package/dist/ai/useAiChat.js +20 -4
  19. package/dist/ai/useAiChat.js.map +2 -2
  20. package/dist/backend/charts/BarChart.js +19 -76
  21. package/dist/backend/charts/BarChart.js.map +2 -2
  22. package/dist/backend/charts/BarChartImpl.js +91 -0
  23. package/dist/backend/charts/BarChartImpl.js.map +7 -0
  24. package/dist/backend/charts/LineChart.js +18 -91
  25. package/dist/backend/charts/LineChart.js.map +2 -2
  26. package/dist/backend/charts/LineChartImpl.js +112 -0
  27. package/dist/backend/charts/LineChartImpl.js.map +7 -0
  28. package/dist/backend/charts/PieChart.js +17 -62
  29. package/dist/backend/charts/PieChart.js.map +2 -2
  30. package/dist/backend/charts/PieChartImpl.js +76 -0
  31. package/dist/backend/charts/PieChartImpl.js.map +7 -0
  32. package/dist/backend/dashboard/DashboardScreen.js +9 -1
  33. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  34. package/dist/backend/dashboard/widgetRegistry.js +18 -2
  35. package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
  36. package/dist/backend/icons/lucideRegistry.generated.js +2 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  38. package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
  39. package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
  40. package/package.json +4 -5
  41. package/src/ai/AiAssistantLauncher.tsx +81 -5
  42. package/src/ai/AiChat.tsx +42 -2
  43. package/src/ai/AiChatSessions.tsx +55 -9
  44. package/src/ai/AiDock.tsx +13 -0
  45. package/src/ai/ConversationShareButton.tsx +39 -0
  46. package/src/ai/ConversationShareDialog.tsx +299 -0
  47. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +6 -0
  48. package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
  49. package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
  50. package/src/ai/__tests__/AiDock.test.tsx +31 -1
  51. package/src/ai/__tests__/conversation-store.test.ts +74 -1
  52. package/src/ai/conversation-store.ts +22 -2
  53. package/src/ai/index.ts +2 -0
  54. package/src/ai/useAiChat.ts +47 -4
  55. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
  56. package/src/backend/charts/BarChart.tsx +21 -72
  57. package/src/backend/charts/BarChartImpl.tsx +99 -0
  58. package/src/backend/charts/LineChart.tsx +23 -85
  59. package/src/backend/charts/LineChartImpl.tsx +120 -0
  60. package/src/backend/charts/PieChart.tsx +19 -60
  61. package/src/backend/charts/PieChartImpl.tsx +82 -0
  62. package/src/backend/dashboard/DashboardScreen.tsx +9 -1
  63. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +45 -0
  64. package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
  65. package/src/backend/dashboard/widgetRegistry.ts +23 -5
  66. package/src/backend/icons/lucideRegistry.generated.tsx +2 -0
  67. package/src/backend/injection/useInjectionDataWidgets.ts +18 -2
package/src/ai/AiChat.tsx CHANGED
@@ -305,6 +305,18 @@ export interface AiChatProps {
305
305
  welcomeDescription?: string
306
306
  /** Initial compact composer state used before the footer has been measured. */
307
307
  defaultCompactFooter?: boolean
308
+ /** Optional extra action nodes rendered in the conversation header bar. */
309
+ headerActions?: React.ReactNode
310
+ /** Called once on mount (and on reset) with the effective conversation id. */
311
+ onConversationIdChange?: (conversationId: string) => void
312
+ /**
313
+ * Called when the server returns 404 for the active `conversationId`,
314
+ * e.g. when the id was carried over from a previous tenant/org scope
315
+ * and no longer exists. The host (typically the AI dock) should remove
316
+ * the corresponding session entry so the stale tab disappears instead
317
+ * of staying empty.
318
+ */
319
+ onConversationNotFound?: () => void
308
320
  }
309
321
 
310
322
  interface ServerEmittedUiPartRef {
@@ -773,16 +785,22 @@ function MessageRow({
773
785
  message,
774
786
  registry,
775
787
  onMutationRequested,
788
+ isOwner,
776
789
  }: {
777
790
  message: AiChatMessage
778
791
  registry?: AiUiPartRegistry
779
792
  onMutationRequested?: (pendingActionId: string) => void
793
+ /** Whether the current viewer owns the conversation. Used to label foreign user messages. */
794
+ isOwner?: boolean | null
780
795
  }) {
781
796
  const t = useT()
782
797
  const isAssistant = message.role === 'assistant'
798
+ const isOtherUsersMessage = !isAssistant && isOwner === false
783
799
  const label = isAssistant
784
800
  ? t('ai_assistant.chat.assistantRoleLabel', 'Assistant')
785
- : t('ai_assistant.chat.userRoleLabel', 'You')
801
+ : isOtherUsersMessage
802
+ ? t('ai_assistant.chat.ownerRoleLabel', 'Owner')
803
+ : t('ai_assistant.chat.userRoleLabel', 'You')
786
804
  const Icon = isAssistant ? Bot : User
787
805
  const [copied, setCopied] = React.useState(false)
788
806
  const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -1135,8 +1153,15 @@ export function AiChat({
1135
1153
  welcomeTitle,
1136
1154
  welcomeDescription,
1137
1155
  defaultCompactFooter = false,
1156
+ headerActions,
1157
+ onConversationIdChange,
1158
+ onConversationNotFound,
1138
1159
  }: AiChatProps) {
1139
1160
  const t = useT()
1161
+ const onConversationIdChangeRef = React.useRef(onConversationIdChange)
1162
+ React.useLayoutEffect(() => {
1163
+ onConversationIdChangeRef.current = onConversationIdChange
1164
+ })
1140
1165
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
1141
1166
  const transcriptRef = React.useRef<HTMLDivElement | null>(null)
1142
1167
  const fileInputRef = React.useRef<HTMLInputElement | null>(null)
@@ -1261,12 +1286,17 @@ export function AiChat({
1261
1286
  conversationId,
1262
1287
  providerOverride: effectiveModelPickerValue?.providerId ?? null,
1263
1288
  modelOverride: effectiveModelPickerValue?.modelId ?? null,
1289
+ onConversationNotFound,
1264
1290
  })
1265
1291
 
1266
1292
  const isStreaming = chat.status === 'streaming'
1267
1293
  const isSubmitting = chat.status === 'submitting'
1268
1294
  const isBusy = isStreaming || isSubmitting
1269
1295
 
1296
+ React.useEffect(() => {
1297
+ onConversationIdChangeRef.current?.(chat.conversationId)
1298
+ }, [chat.conversationId])
1299
+
1270
1300
  // Surface a "Thinking..." placeholder so the chat does not look frozen.
1271
1301
  // Visible whenever ANY of the following is true while a turn is in flight:
1272
1302
  // (a) we're still in the submit phase before the first stream chunk
@@ -1569,6 +1599,7 @@ export function AiChat({
1569
1599
  </span>
1570
1600
  )}
1571
1601
  </div>
1602
+ {headerActions ?? null}
1572
1603
  <IconButton
1573
1604
  type="button"
1574
1605
  variant="ghost"
@@ -1603,6 +1634,7 @@ export function AiChat({
1603
1634
  message={message}
1604
1635
  registry={activeRegistry}
1605
1636
  onMutationRequested={onMutationRequested}
1637
+ isOwner={chat.isOwner}
1606
1638
  />
1607
1639
  ))
1608
1640
  )}
@@ -1639,8 +1671,16 @@ export function AiChat({
1639
1671
  </Alert>
1640
1672
  ) : null}
1641
1673
 
1674
+ {chat.isOwner === false ? (
1675
+ <div
1676
+ className="flex items-center justify-center rounded-md border border-border bg-muted/30 px-3 py-3 text-xs text-muted-foreground"
1677
+ data-ai-chat-read-only-notice=""
1678
+ >
1679
+ {t('ai_assistant.chat.readOnlyNotice', 'This is a shared conversation. You can read but not reply.')}
1680
+ </div>
1681
+ ) : null}
1642
1682
  <form
1643
- className="flex min-w-0 flex-col gap-2"
1683
+ className={cn('flex min-w-0 flex-col gap-2', chat.isOwner === false && 'hidden')}
1644
1684
  onSubmit={(event) => {
1645
1685
  event.preventDefault()
1646
1686
  handleSubmit()
@@ -20,6 +20,10 @@
20
20
  */
21
21
 
22
22
  import * as React from 'react'
23
+ import {
24
+ getCurrentOrganizationScope,
25
+ subscribeOrganizationScopeChanged,
26
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
23
27
  import {
24
28
  createAiServerConversation,
25
29
  listAiServerConversations,
@@ -27,9 +31,27 @@ import {
27
31
  type AiServerConversation,
28
32
  } from './conversation-store'
29
33
 
30
- const STORAGE_KEY = 'om-ai-chat-sessions-v1'
34
+ /**
35
+ * Legacy app-global storage key used before tenant/org scoping.
36
+ *
37
+ * Kept only as a documented constant for grep / debugging. No code path
38
+ * reads or writes it any more — the legacy entry is intentionally
39
+ * abandoned (not migrated) because the origin scope of any data stored
40
+ * under this key is unknown; silently importing it into the wrong scope
41
+ * is worse than empty state. `listAiServerConversations` repopulates
42
+ * sessions from the authoritative server source on first load.
43
+ */
44
+ export const LEGACY_STORAGE_KEY = 'om-ai-chat-sessions-v1'
45
+ const STORAGE_KEY_PREFIX = 'om-ai-chat-sessions-v1'
31
46
  const HISTORY_LIMIT = 50
32
47
 
48
+ function getScopedStorageKey(
49
+ tenantId: string | null | undefined,
50
+ organizationId: string | null | undefined,
51
+ ): string {
52
+ return `${STORAGE_KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`
53
+ }
54
+
33
55
  export type AiChatSessionStatus = 'open' | 'closed'
34
56
 
35
57
  export interface AiChatSession {
@@ -86,10 +108,10 @@ function makeId(): string {
86
108
  return `${Date.now().toString(16)}-${rand()}-${rand()}`
87
109
  }
88
110
 
89
- function readPersisted(): AiChatSessionsState {
111
+ function readPersisted(storageKey: string): AiChatSessionsState {
90
112
  if (typeof window === 'undefined') return { sessions: [], activeByAgent: {} }
91
113
  try {
92
- const raw = window.localStorage.getItem(STORAGE_KEY)
114
+ const raw = window.localStorage.getItem(storageKey)
93
115
  if (!raw) return { sessions: [], activeByAgent: {} }
94
116
  const parsed = JSON.parse(raw) as Partial<AiChatSessionsState> | null
95
117
  const sessions = Array.isArray(parsed?.sessions)
@@ -130,10 +152,10 @@ function readPersisted(): AiChatSessionsState {
130
152
  }
131
153
  }
132
154
 
133
- function writePersisted(state: AiChatSessionsState): void {
155
+ function writePersisted(storageKey: string, state: AiChatSessionsState): void {
134
156
  if (typeof window === 'undefined') return
135
157
  try {
136
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
158
+ window.localStorage.setItem(storageKey, JSON.stringify(state))
137
159
  } catch {
138
160
  /* quota / privacy mode — drop silently */
139
161
  }
@@ -194,11 +216,34 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
194
216
  // server (no `window`), so SSR stays consistent with the bare-bones
195
217
  // shell — actual session-dependent UI only renders after a user
196
218
  // interaction opens a chat surface.
197
- const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted())
219
+ //
220
+ // The storage key is scoped to the current tenant/organization so a
221
+ // scope switch in the topbar does not surface another tenant's
222
+ // sessions. `getCurrentOrganizationScope()` reads module-level state
223
+ // populated by the topbar before the provider mounts; a `null` scope
224
+ // (pre-resolution) lands in a harmless `no-tenant:no-org` bucket that
225
+ // the scope-change subscription below immediately corrects when the
226
+ // real scope arrives.
227
+ const [storageKey, setStorageKey] = React.useState<string>(() => {
228
+ const scope = getCurrentOrganizationScope()
229
+ return getScopedStorageKey(scope.tenantId, scope.organizationId)
230
+ })
231
+ const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted(storageKey))
232
+
233
+ React.useEffect(() => {
234
+ return subscribeOrganizationScopeChanged((detail) => {
235
+ const nextKey = getScopedStorageKey(detail.tenantId, detail.organizationId)
236
+ if (nextKey === storageKey) return
237
+ // React 18 batches both updates within the synchronous handler so
238
+ // there is no intermediate render with a mismatched key/state pair.
239
+ setStorageKey(nextKey)
240
+ setState(readPersisted(nextKey))
241
+ })
242
+ }, [storageKey])
198
243
 
199
244
  React.useEffect(() => {
200
- writePersisted(state)
201
- }, [state])
245
+ writePersisted(storageKey, state)
246
+ }, [storageKey, state])
202
247
 
203
248
  React.useEffect(() => {
204
249
  let cancelled = false
@@ -209,7 +254,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
209
254
  return () => {
210
255
  cancelled = true
211
256
  }
212
- }, [])
257
+ }, [storageKey])
213
258
 
214
259
  const update = React.useCallback(
215
260
  (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
@@ -322,6 +367,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
322
367
  update((prev) => {
323
368
  const target = prev.sessions.find((s) => s.id === sessionId)
324
369
  if (!target || target.status !== 'open') return prev
370
+ if (prev.activeByAgent[target.agentId] === sessionId) return prev
325
371
  const sessions = prev.sessions.map((s) =>
326
372
  s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,
327
373
  )
package/src/ai/AiDock.tsx CHANGED
@@ -19,6 +19,7 @@ import { Maximize2, Minimize2, X } from 'lucide-react'
19
19
  import type { AiChatContextItem, AiChatSuggestion } from './AiChat'
20
20
  import { ChatPaneTabs } from './ChatPaneTabs'
21
21
  import { useAiChatSessions } from './AiChatSessions'
22
+ import { ConversationShareButton } from './ConversationShareButton'
22
23
  import { IconButton } from '../primitives/icon-button'
23
24
  import { useT } from '@open-mercato/shared/lib/i18n/context'
24
25
  import { cn } from '@open-mercato/shared/lib/utils'
@@ -475,6 +476,16 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
475
476
  if (!session) sessions.ensureSession(assistant.agent)
476
477
  }, [assistant.agent, session, sessions])
477
478
 
479
+ const activeSessionId = session?.id ?? null
480
+ const handleConversationNotFound = React.useCallback(() => {
481
+ // The server returned 404 for this session's conversationId. The
482
+ // conversation does not exist in the current tenant/org scope (most
483
+ // commonly the user just switched scope and a stale id rode along in
484
+ // localStorage). Close the tab so the dock surfaces the empty state
485
+ // for the new scope rather than a perpetual blank chat.
486
+ if (activeSessionId) sessions.closeSession(activeSessionId)
487
+ }, [activeSessionId, sessions])
488
+
478
489
  return (
479
490
  <>
480
491
  <ChatPaneTabs agentId={assistant.agent} className="border-b" />
@@ -497,6 +508,8 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
497
508
  contextItems={assistant.contextItems}
498
509
  welcomeTitle={assistant.welcomeTitle}
499
510
  welcomeDescription={assistant.welcomeDescription}
511
+ headerActions={<ConversationShareButton conversationId={session.conversationId} />}
512
+ onConversationNotFound={handleConversationNotFound}
500
513
  />
501
514
  </React.Suspense>
502
515
  ) : null}
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Share2 } from 'lucide-react'
5
+ import { IconButton } from '../primitives/icon-button'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { ConversationShareDialog } from './ConversationShareDialog'
8
+
9
+ interface Props {
10
+ conversationId: string
11
+ }
12
+
13
+ export function ConversationShareButton({ conversationId }: Props) {
14
+ const t = useT()
15
+ const [dialogOpen, setDialogOpen] = React.useState(false)
16
+
17
+ return (
18
+ <>
19
+ <IconButton
20
+ type="button"
21
+ variant="ghost"
22
+ size="sm"
23
+ onClick={() => setDialogOpen(true)}
24
+ aria-label={t('ai_assistant.share.shareButton', 'Share')}
25
+ title={t('ai_assistant.share.shareButton', 'Share')}
26
+ data-ai-chat-share-conversation=""
27
+ >
28
+ <Share2 className="size-4" aria-hidden />
29
+ </IconButton>
30
+ <ConversationShareDialog
31
+ open={dialogOpen}
32
+ onOpenChange={setDialogOpen}
33
+ conversationId={conversationId}
34
+ />
35
+ </>
36
+ )
37
+ }
38
+
39
+ export default ConversationShareButton
@@ -0,0 +1,299 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { UserPlus, X, Loader2 } from 'lucide-react'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '../primitives/dialog'
13
+ import { Button } from '../primitives/button'
14
+ import { Input } from '../primitives/input'
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '../primitives/select'
22
+ import { apiCall } from '../backend/utils/apiCall'
23
+ import { useCurrentUserId } from '../backend/utils/useCurrentUserId'
24
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
25
+
26
+ interface Participant {
27
+ userId: string
28
+ role: string
29
+ lastReadAt: string | null
30
+ addedAt: string
31
+ }
32
+
33
+ interface UserOption {
34
+ id: string
35
+ email: string
36
+ name: string | null
37
+ }
38
+
39
+ interface Props {
40
+ open: boolean
41
+ onOpenChange: (next: boolean) => void
42
+ conversationId: string
43
+ }
44
+
45
+ export function ConversationShareDialog({ open, onOpenChange, conversationId }: Props) {
46
+ const t = useT()
47
+ const currentUserId = useCurrentUserId()
48
+ const [participants, setParticipants] = React.useState<Participant[]>([])
49
+ const [ownerUserId, setOwnerUserId] = React.useState<string | null>(null)
50
+ const [users, setUsers] = React.useState<UserOption[]>([])
51
+ const [canListUsers, setCanListUsers] = React.useState(true)
52
+ const [loading, setLoading] = React.useState(false)
53
+ const [loadingUsers, setLoadingUsers] = React.useState(false)
54
+ const [selectedUserId, setSelectedUserId] = React.useState('')
55
+ const [textUserId, setTextUserId] = React.useState('')
56
+ const [adding, setAdding] = React.useState(false)
57
+ const [removingId, setRemovingId] = React.useState<string | null>(null)
58
+ const [error, setError] = React.useState<string | null>(null)
59
+
60
+ const baseUrl = `/api/ai_assistant/ai/conversations/${conversationId}/participants`
61
+
62
+ const fetchParticipants = React.useCallback(async () => {
63
+ setLoading(true)
64
+ setError(null)
65
+ try {
66
+ const res = await apiCall<{ participants: Participant[]; ownerUserId?: string | null }>(baseUrl)
67
+ if (!res.ok || !res.result) throw new Error('fetch failed')
68
+ setParticipants(res.result.participants.filter((p) => p.role !== 'owner'))
69
+ setOwnerUserId(res.result.ownerUserId ?? null)
70
+ } catch {
71
+ setError(t('common.error', 'Something went wrong.'))
72
+ } finally {
73
+ setLoading(false)
74
+ }
75
+ }, [conversationId, t])
76
+
77
+ const fetchUsers = React.useCallback(async () => {
78
+ setLoadingUsers(true)
79
+ try {
80
+ const res = await apiCall<{ items: UserOption[] }>('/api/auth/users?limit=200')
81
+ if (res.ok && res.result) {
82
+ setUsers(res.result.items)
83
+ setCanListUsers(true)
84
+ } else {
85
+ setCanListUsers(false)
86
+ }
87
+ } catch {
88
+ setCanListUsers(false)
89
+ } finally {
90
+ setLoadingUsers(false)
91
+ }
92
+ }, [])
93
+
94
+ React.useEffect(() => {
95
+ if (open) {
96
+ setSelectedUserId('')
97
+ setTextUserId('')
98
+ setError(null)
99
+ void Promise.all([fetchParticipants(), fetchUsers()])
100
+ }
101
+ }, [open, fetchParticipants, fetchUsers])
102
+
103
+ const participantIds = React.useMemo(
104
+ () => new Set(participants.map((p) => p.userId)),
105
+ [participants],
106
+ )
107
+
108
+ const availableUsers = React.useMemo(
109
+ () =>
110
+ users.filter(
111
+ (u) => !participantIds.has(u.id) && u.id !== currentUserId && u.id !== ownerUserId,
112
+ ),
113
+ [users, participantIds, currentUserId, ownerUserId],
114
+ )
115
+
116
+ const activeUserId = canListUsers ? selectedUserId : textUserId.trim()
117
+
118
+ const handleAdd = React.useCallback(async () => {
119
+ if (!activeUserId) return
120
+ setAdding(true)
121
+ setError(null)
122
+ try {
123
+ const res = await apiCall<{ participant: Participant }>(baseUrl, {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({ userId: activeUserId, role: 'viewer' }),
127
+ })
128
+ if (!res.ok || !res.result) throw new Error('fetch failed')
129
+ setParticipants((prev) => {
130
+ const next = res.result!.participant
131
+ const exists = prev.find((p) => p.userId === next.userId)
132
+ if (exists) return prev.map((p) => (p.userId === next.userId ? next : p))
133
+ return [...prev, next]
134
+ })
135
+ setSelectedUserId('')
136
+ setTextUserId('')
137
+ } catch {
138
+ setError(t('common.error', 'Something went wrong.'))
139
+ } finally {
140
+ setAdding(false)
141
+ }
142
+ }, [activeUserId, baseUrl, t])
143
+
144
+ React.useEffect(() => {
145
+ const handler = (e: KeyboardEvent) => {
146
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
147
+ e.preventDefault()
148
+ if (activeUserId && !adding) void handleAdd()
149
+ }
150
+ }
151
+ if (open) document.addEventListener('keydown', handler)
152
+ return () => document.removeEventListener('keydown', handler)
153
+ }, [open, activeUserId, adding, handleAdd])
154
+
155
+ const handleRemove = async (userId: string) => {
156
+ setRemovingId(userId)
157
+ setError(null)
158
+ try {
159
+ await apiCall(`${baseUrl}/${userId}`, { method: 'DELETE', headers: { 'content-type': 'application/json' } })
160
+ setParticipants((prev) => prev.filter((p) => p.userId !== userId))
161
+ } catch {
162
+ setError(t('common.error', 'Something went wrong.'))
163
+ } finally {
164
+ setRemovingId(null)
165
+ }
166
+ }
167
+
168
+ const formatUserLabel = (u: UserOption) => (u.name ? `${u.name} — ${u.email}` : u.email)
169
+
170
+ const getUserLabel = (userId: string) => {
171
+ const u = users.find((u) => u.id === userId)
172
+ return u ? formatUserLabel(u) : userId
173
+ }
174
+
175
+ return (
176
+ <Dialog open={open} onOpenChange={onOpenChange}>
177
+ <DialogContent className="sm:max-w-md">
178
+ <DialogHeader>
179
+ <DialogTitle>
180
+ {t('ai_assistant.share.dialogTitle', 'Share Conversation')}
181
+ </DialogTitle>
182
+ <DialogDescription>
183
+ {t(
184
+ 'ai_assistant.share.dialogDescription',
185
+ 'Share this conversation with other users. They will get read-only access.',
186
+ )}
187
+ </DialogDescription>
188
+ </DialogHeader>
189
+
190
+ <div className="space-y-4">
191
+ <div className="flex gap-2">
192
+ {canListUsers === false ? (
193
+ <Input
194
+ placeholder={t('ai_assistant.share.participantPlaceholder', 'User ID...')}
195
+ value={textUserId}
196
+ onChange={(e) => setTextUserId(e.target.value)}
197
+ onKeyDown={(e) => {
198
+ if (e.key === 'Enter') { e.preventDefault(); void handleAdd() }
199
+ }}
200
+ className="flex-1"
201
+ />
202
+ ) : (
203
+ <Select
204
+ value={selectedUserId}
205
+ onValueChange={setSelectedUserId}
206
+ disabled={loadingUsers || availableUsers.length === 0}
207
+ >
208
+ <SelectTrigger className="flex-1">
209
+ <SelectValue
210
+ placeholder={
211
+ loadingUsers
212
+ ? t('common.loading', 'Loading...')
213
+ : availableUsers.length === 0
214
+ ? t('ai_assistant.share.allUsersAdded', 'All users already added')
215
+ : t('ai_assistant.share.selectUser', 'Select a user...')
216
+ }
217
+ />
218
+ </SelectTrigger>
219
+ <SelectContent>
220
+ {availableUsers.map((u) => (
221
+ <SelectItem key={u.id} value={u.id}>
222
+ {formatUserLabel(u)}
223
+ </SelectItem>
224
+ ))}
225
+ </SelectContent>
226
+ </Select>
227
+ )}
228
+ <Button
229
+ type="button"
230
+ onClick={() => void handleAdd()}
231
+ disabled={!activeUserId || adding}
232
+ size="default"
233
+ >
234
+ {adding ? (
235
+ <Loader2 className="size-4 animate-spin" />
236
+ ) : (
237
+ <UserPlus className="size-4" />
238
+ )}
239
+ {t('ai_assistant.share.addParticipant', 'Add')}
240
+ </Button>
241
+ </div>
242
+
243
+ {error ? (
244
+ <p className="text-sm text-status-danger-text">{error}</p>
245
+ ) : null}
246
+
247
+ <div className="space-y-2 min-h-[60px]">
248
+ {loading ? (
249
+ <div className="flex items-center justify-center py-4">
250
+ <Loader2 className="size-5 animate-spin text-muted-foreground" />
251
+ </div>
252
+ ) : participants.length === 0 ? (
253
+ <p className="text-sm text-muted-foreground py-2">
254
+ {t(
255
+ 'ai_assistant.share.noParticipants',
256
+ 'No participants yet. Add someone to share this conversation.',
257
+ )}
258
+ </p>
259
+ ) : (
260
+ participants.map((p) => (
261
+ <div
262
+ key={p.userId}
263
+ className="flex items-center justify-between rounded-lg border border-border px-3 py-2"
264
+ >
265
+ <div className="min-w-0 flex-1">
266
+ <p className="truncate text-sm text-foreground">{getUserLabel(p.userId)}</p>
267
+ <p className="text-xs text-muted-foreground capitalize">{p.role}</p>
268
+ </div>
269
+ <Button
270
+ type="button"
271
+ variant="ghost"
272
+ size="icon"
273
+ onClick={() => void handleRemove(p.userId)}
274
+ disabled={removingId === p.userId}
275
+ aria-label={t('ai_assistant.share.removeParticipant', 'Remove')}
276
+ >
277
+ {removingId === p.userId ? (
278
+ <Loader2 className="size-3 animate-spin" />
279
+ ) : (
280
+ <X className="size-4" />
281
+ )}
282
+ </Button>
283
+ </div>
284
+ ))
285
+ )}
286
+ </div>
287
+ </div>
288
+
289
+ <DialogFooter>
290
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
291
+ {t('common.close', 'Close')}
292
+ </Button>
293
+ </DialogFooter>
294
+ </DialogContent>
295
+ </Dialog>
296
+ )
297
+ }
298
+
299
+ export default ConversationShareDialog
@@ -8,6 +8,12 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
8
8
  import { apiCall } from '../../backend/utils/apiCall'
9
9
  import { AiAssistantLauncher, AI_ASSISTANT_LAUNCHER_OPEN_EVENT } from '../AiAssistantLauncher'
10
10
 
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ replace: jest.fn() }),
13
+ usePathname: () => '/',
14
+ useSearchParams: () => new URLSearchParams(),
15
+ }))
16
+
11
17
  jest.mock('../../backend/utils/apiCall', () => ({
12
18
  apiCall: jest.fn(),
13
19
  }))