@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/ai/AiAssistantLauncher.js +61 -8
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +29 -6
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +31 -9
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/AiDock.js +8 -1
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ConversationShareButton.js +40 -0
- package/dist/ai/ConversationShareButton.js.map +7 -0
- package/dist/ai/ConversationShareDialog.js +234 -0
- package/dist/ai/ConversationShareDialog.js.map +7 -0
- package/dist/ai/conversation-store.js +4 -2
- package/dist/ai/conversation-store.js.map +2 -2
- package/dist/ai/index.js +4 -0
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +20 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/charts/BarChart.js +19 -76
- package/dist/backend/charts/BarChart.js.map +2 -2
- package/dist/backend/charts/BarChartImpl.js +91 -0
- package/dist/backend/charts/BarChartImpl.js.map +7 -0
- package/dist/backend/charts/LineChart.js +18 -91
- package/dist/backend/charts/LineChart.js.map +2 -2
- package/dist/backend/charts/LineChartImpl.js +112 -0
- package/dist/backend/charts/LineChartImpl.js.map +7 -0
- package/dist/backend/charts/PieChart.js +17 -62
- package/dist/backend/charts/PieChart.js.map +2 -2
- package/dist/backend/charts/PieChartImpl.js +76 -0
- package/dist/backend/charts/PieChartImpl.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +9 -1
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/dashboard/widgetRegistry.js +18 -2
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +2 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
- package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
- package/package.json +4 -5
- package/src/ai/AiAssistantLauncher.tsx +81 -5
- package/src/ai/AiChat.tsx +42 -2
- package/src/ai/AiChatSessions.tsx +55 -9
- package/src/ai/AiDock.tsx +13 -0
- package/src/ai/ConversationShareButton.tsx +39 -0
- package/src/ai/ConversationShareDialog.tsx +299 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +6 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
- package/src/ai/__tests__/AiDock.test.tsx +31 -1
- package/src/ai/__tests__/conversation-store.test.ts +74 -1
- package/src/ai/conversation-store.ts +22 -2
- package/src/ai/index.ts +2 -0
- package/src/ai/useAiChat.ts +47 -4
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
- package/src/backend/charts/BarChart.tsx +21 -72
- package/src/backend/charts/BarChartImpl.tsx +99 -0
- package/src/backend/charts/LineChart.tsx +23 -85
- package/src/backend/charts/LineChartImpl.tsx +120 -0
- package/src/backend/charts/PieChart.tsx +19 -60
- package/src/backend/charts/PieChartImpl.tsx +82 -0
- package/src/backend/dashboard/DashboardScreen.tsx +9 -1
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +45 -0
- package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
- package/src/backend/dashboard/widgetRegistry.ts +23 -5
- package/src/backend/icons/lucideRegistry.generated.tsx +2 -0
- 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
|
-
:
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}))
|