@open-mercato/ui 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6201.1.8ceb502c4b
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 +1 -1
- package/dist/ai/AiChat.js +1 -28
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +35 -32
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/modelPickerStorage.js +29 -0
- package/dist/ai/modelPickerStorage.js.map +7 -0
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/AppShell.js +24 -19
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +26 -23
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/notifications/useNotificationActions.js +5 -5
- package/dist/backend/notifications/useNotificationActions.js.map +2 -2
- package/dist/backend/progress/ProgressTopBar.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +1 -37
- package/src/ai/AiChatSessions.tsx +54 -48
- package/src/ai/__tests__/AiChat.modelPickerStorage.test.ts +40 -0
- package/src/ai/__tests__/AiChatSessions.storage.test.ts +42 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +10 -6
- package/src/ai/modelPickerStorage.ts +40 -0
- package/src/ai/useAiChat.ts +7 -0
- package/src/backend/AppShell.tsx +41 -17
- package/src/backend/DataTable.tsx +34 -29
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +44 -0
- package/src/backend/notifications/__tests__/useNotificationActions.failedWrites.test.tsx +119 -0
- package/src/backend/notifications/useNotificationActions.ts +5 -5
- package/src/backend/progress/ProgressTopBar.tsx +4 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import * as React from "react";
|
|
3
|
-
import { apiCall } from "../utils/apiCall.js";
|
|
3
|
+
import { apiCall, apiCallOrThrow } from "../utils/apiCall.js";
|
|
4
4
|
function useNotificationActions(notifications, setNotifications, setUnreadCount) {
|
|
5
5
|
const markAsReadRef = React.useRef(async () => {
|
|
6
6
|
});
|
|
@@ -9,7 +9,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
9
9
|
const [dismissUndo, setDismissUndo] = React.useState(null);
|
|
10
10
|
const dismissUndoTimerRef = React.useRef(null);
|
|
11
11
|
const markAsRead = React.useCallback(async (id) => {
|
|
12
|
-
await
|
|
12
|
+
await apiCallOrThrow(`/api/notifications/${id}/read`, { method: "PUT" });
|
|
13
13
|
setNotifications(
|
|
14
14
|
(prev) => prev.map(
|
|
15
15
|
(n) => n.id === id ? { ...n, status: "read", readAt: (/* @__PURE__ */ new Date()).toISOString() } : n
|
|
@@ -41,7 +41,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
41
41
|
}, [setNotifications, setUnreadCount]);
|
|
42
42
|
const dismiss = React.useCallback(
|
|
43
43
|
async (id) => {
|
|
44
|
-
await
|
|
44
|
+
await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: "PUT" });
|
|
45
45
|
const notification = notifications.find((n) => n.id === id);
|
|
46
46
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
47
47
|
if (notification?.status === "unread") {
|
|
@@ -65,7 +65,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
65
65
|
}, [dismiss]);
|
|
66
66
|
const undoDismiss = React.useCallback(async () => {
|
|
67
67
|
if (!dismissUndo) return;
|
|
68
|
-
await
|
|
68
|
+
await apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
|
|
69
69
|
method: "PUT",
|
|
70
70
|
headers: { "content-type": "application/json" },
|
|
71
71
|
body: JSON.stringify({ status: dismissUndo.previousStatus })
|
|
@@ -92,7 +92,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
92
92
|
setDismissUndo(null);
|
|
93
93
|
}, [dismissUndo, setNotifications, setUnreadCount]);
|
|
94
94
|
const markAllRead = React.useCallback(async () => {
|
|
95
|
-
await
|
|
95
|
+
await apiCallOrThrow("/api/notifications/mark-all-read", { method: "PUT" });
|
|
96
96
|
setNotifications(
|
|
97
97
|
(prev) => prev.map(
|
|
98
98
|
(n) => n.status === "unread" ? { ...n, status: "read", readAt: (/* @__PURE__ */ new Date()).toISOString() } : n
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/useNotificationActions.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\n\nexport type NotificationDismissUndoState = {\n notification: NotificationDto\n previousStatus: 'read' | 'unread'\n} | null\n\ntype SetUnreadCount = React.Dispatch<React.SetStateAction<number>>\n\nexport type NotificationActionsResult = {\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: NotificationDismissUndoState\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n markAsReadRef: React.MutableRefObject<(id: string) => Promise<void>>\n dismissRef: React.MutableRefObject<(id: string) => Promise<void>>\n}\n\nexport function useNotificationActions(\n notifications: NotificationDto[],\n setNotifications: React.Dispatch<React.SetStateAction<NotificationDto[]>>,\n setUnreadCount: SetUnreadCount,\n): NotificationActionsResult {\n const markAsReadRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const dismissRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)\n const dismissUndoTimerRef = React.useRef<number | null>(null)\n\n const markAsRead = React.useCallback(async (id: string) => {\n await
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall, apiCallOrThrow } from '../utils/apiCall'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\n\nexport type NotificationDismissUndoState = {\n notification: NotificationDto\n previousStatus: 'read' | 'unread'\n} | null\n\ntype SetUnreadCount = React.Dispatch<React.SetStateAction<number>>\n\nexport type NotificationActionsResult = {\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: NotificationDismissUndoState\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n markAsReadRef: React.MutableRefObject<(id: string) => Promise<void>>\n dismissRef: React.MutableRefObject<(id: string) => Promise<void>>\n}\n\nexport function useNotificationActions(\n notifications: NotificationDto[],\n setNotifications: React.Dispatch<React.SetStateAction<NotificationDto[]>>,\n setUnreadCount: SetUnreadCount,\n): NotificationActionsResult {\n const markAsReadRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const dismissRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)\n const dismissUndoTimerRef = React.useRef<number | null>(null)\n\n const markAsRead = React.useCallback(async (id: string) => {\n await apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n markAsReadRef.current = markAsRead\n }, [markAsRead])\n\n const executeAction = React.useCallback(async (id: string, actionId: string) => {\n const result = await apiCall<{ ok: boolean; href?: string }>(\n `/api/notifications/${id}/action`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ actionId }),\n },\n )\n\n if (result.ok) {\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'actioned', actionTaken: actionId } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n\n return { href: result.result?.href }\n }, [setNotifications, setUnreadCount])\n\n const dismiss = React.useCallback(\n async (id: string) => {\n await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' })\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n if (notification) {\n const previousStatus = notification.status === 'unread' ? 'unread' : 'read'\n setDismissUndo({ notification, previousStatus })\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n dismissUndoTimerRef.current = window.setTimeout(() => {\n setDismissUndo(null)\n }, 6000)\n }\n },\n [notifications, setNotifications, setUnreadCount],\n )\n\n React.useEffect(() => {\n dismissRef.current = dismiss\n }, [dismiss])\n\n const undoDismiss = React.useCallback(async () => {\n if (!dismissUndo) return\n await apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: dismissUndo.previousStatus }),\n })\n\n setNotifications((prev) => {\n const next = [\n {\n ...dismissUndo.notification,\n status: dismissUndo.previousStatus,\n readAt:\n dismissUndo.previousStatus === 'unread'\n ? null\n : dismissUndo.notification.readAt ?? new Date().toISOString(),\n },\n ...prev.filter((n) => n.id !== dismissUndo.notification.id),\n ]\n return next.sort(\n (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n )\n })\n\n if (dismissUndo.previousStatus === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n setDismissUndo(null)\n }, [dismissUndo, setNotifications, setUnreadCount])\n\n const markAllRead = React.useCallback(async () => {\n await apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) =>\n n.status === 'unread'\n ? { ...n, status: 'read', readAt: new Date().toISOString() }\n : n,\n ),\n )\n setUnreadCount(0)\n }, [setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n return () => {\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n }\n }, [])\n\n return {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n }\n}\n"],
|
|
5
|
+
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,SAAS,sBAAsB;AAqBjC,SAAS,uBACd,eACA,kBACA,gBAC2B;AAC3B,QAAM,gBAAgB,MAAM,OAAsC,YAAY;AAAA,EAAC,CAAC;AAChF,QAAM,aAAa,MAAM,OAAsC,YAAY;AAAA,EAAC,CAAC;AAC7E,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAuC,IAAI;AACvF,QAAM,sBAAsB,MAAM,OAAsB,IAAI;AAE5D,QAAM,aAAa,MAAM,YAAY,OAAO,OAAe;AACzD,UAAM,eAAe,sBAAsB,EAAE,SAAS,EAAE,QAAQ,MAAM,CAAC;AACvE;AAAA,MAAiB,CAAC,SAChB,KAAK;AAAA,QAAI,CAAC,MACR,EAAE,OAAO,KAAK,EAAE,GAAG,GAAG,QAAQ,QAAQ,SAAQ,oBAAI,KAAK,GAAE,YAAY,EAAE,IAAI;AAAA,MAC7E;AAAA,IACF;AACA,mBAAe,CAAC,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,CAAC;AAAA,EAChD,GAAG,CAAC,kBAAkB,cAAc,CAAC;AAErC,QAAM,UAAU,MAAM;AACpB,kBAAc,UAAU;AAAA,EAC1B,GAAG,CAAC,UAAU,CAAC;AAEf,QAAM,gBAAgB,MAAM,YAAY,OAAO,IAAY,aAAqB;AAC9E,UAAM,SAAS,MAAM;AAAA,MACnB,sBAAsB,EAAE;AAAA,MACxB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;AAAA,MACnC;AAAA,IACF;AAEA,QAAI,OAAO,IAAI;AACb;AAAA,QAAiB,CAAC,SAChB,KAAK;AAAA,UAAI,CAAC,MACR,EAAE,OAAO,KAAK,EAAE,GAAG,GAAG,QAAQ,YAAY,aAAa,SAAS,IAAI;AAAA,QACtE;AAAA,MACF;AACA,qBAAe,CAAC,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,CAAC;AAAA,IAChD;AAEA,WAAO,EAAE,MAAM,OAAO,QAAQ,KAAK;AAAA,EACrC,GAAG,CAAC,kBAAkB,cAAc,CAAC;AAErC,QAAM,UAAU,MAAM;AAAA,IACpB,OAAO,OAAe;AACpB,YAAM,eAAe,sBAAsB,EAAE,YAAY,EAAE,QAAQ,MAAM,CAAC;AAC1E,YAAM,eAAe,cAAc,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE;AAC1D,uBAAiB,CAAC,SAAS,KAAK,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;AAC1D,UAAI,cAAc,WAAW,UAAU;AACrC,uBAAe,CAAC,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,CAAC;AAAA,MAChD;AACA,UAAI,cAAc;AAChB,cAAM,iBAAiB,aAAa,WAAW,WAAW,WAAW;AACrE,uBAAe,EAAE,cAAc,eAAe,CAAC;AAC/C,YAAI,oBAAoB,SAAS;AAC/B,iBAAO,aAAa,oBAAoB,OAAO;AAAA,QACjD;AACA,4BAAoB,UAAU,OAAO,WAAW,MAAM;AACpD,yBAAe,IAAI;AAAA,QACrB,GAAG,GAAI;AAAA,MACT;AAAA,IACF;AAAA,IACA,CAAC,eAAe,kBAAkB,cAAc;AAAA,EAClD;AAEA,QAAM,UAAU,MAAM;AACpB,eAAW,UAAU;AAAA,EACvB,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,cAAc,MAAM,YAAY,YAAY;AAChD,QAAI,CAAC,YAAa;AAClB,UAAM,eAAe,sBAAsB,YAAY,aAAa,EAAE,YAAY;AAAA,MAChF,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,QAAQ,YAAY,eAAe,CAAC;AAAA,IAC7D,CAAC;AAED,qBAAiB,CAAC,SAAS;AACzB,YAAM,OAAO;AAAA,QACX;AAAA,UACE,GAAG,YAAY;AAAA,UACf,QAAQ,YAAY;AAAA,UACpB,QACE,YAAY,mBAAmB,WAC3B,OACA,YAAY,aAAa,WAAU,oBAAI,KAAK,GAAE,YAAY;AAAA,QAClE;AAAA,QACA,GAAG,KAAK,OAAO,CAAC,MAAM,EAAE,OAAO,YAAY,aAAa,EAAE;AAAA,MAC5D;AACA,aAAO,KAAK;AAAA,QACV,CAAC,GAAG,MAAM,IAAI,KAAK,EAAE,SAAS,EAAE,QAAQ,IAAI,IAAI,KAAK,EAAE,SAAS,EAAE,QAAQ;AAAA,MAC5E;AAAA,IACF,CAAC;AAED,QAAI,YAAY,mBAAmB,UAAU;AAC3C,qBAAe,CAAC,SAAS,OAAO,CAAC;AAAA,IACnC;AAEA,QAAI,oBAAoB,SAAS;AAC/B,aAAO,aAAa,oBAAoB,OAAO;AAAA,IACjD;AACA,mBAAe,IAAI;AAAA,EACrB,GAAG,CAAC,aAAa,kBAAkB,cAAc,CAAC;AAElD,QAAM,cAAc,MAAM,YAAY,YAAY;AAChD,UAAM,eAAe,oCAAoC,EAAE,QAAQ,MAAM,CAAC;AAC1E;AAAA,MAAiB,CAAC,SAChB,KAAK;AAAA,QAAI,CAAC,MACR,EAAE,WAAW,WACT,EAAE,GAAG,GAAG,QAAQ,QAAQ,SAAQ,oBAAI,KAAK,GAAE,YAAY,EAAE,IACzD;AAAA,MACN;AAAA,IACF;AACA,mBAAe,CAAC;AAAA,EAClB,GAAG,CAAC,kBAAkB,cAAc,CAAC;AAErC,QAAM,UAAU,MAAM;AACpB,WAAO,MAAM;AACX,UAAI,oBAAoB,SAAS;AAC/B,eAAO,aAAa,oBAAoB,OAAO;AAAA,MACjD;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/progress/ProgressTopBar.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { Progress } from '../../primitives/progress'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useProgress } from './useProgress'\nimport { useAutoHideCompletedJobs } from './useAutoHideCompletedJobs'\nimport type { ProgressJobDto } from './useProgressPoll'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '../utils/apiCall'\n\nexport type ProgressTopBarProps = {\n className?: string\n t: TranslateFn\n /**\n * How long (ms) to keep successfully completed jobs visible before auto-hiding.\n * Set to `false` or `0` to disable auto-hide. Defaults to 10 000 ms.\n * Failed and cancelled jobs are never auto-hidden.\n */\n completedAutoHideMs?: number | false\n}\n\nexport function ProgressTopBar({ className, t, completedAutoHideMs }: ProgressTopBarProps) {\n const { activeJobs, recentlyCompleted, refresh } = useProgress()\n const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)\n const [expanded, setExpanded] = React.useState(false)\n\n React.useEffect(() => {\n const saved = localStorage.getItem('om:progress:expanded')\n if (saved === 'true') setExpanded(true)\n }, [])\n\n React.useEffect(() => {\n localStorage.setItem('om:progress:expanded', String(expanded))\n }, [expanded])\n\n const hasActiveJobs = activeJobs.length > 0\n const hasRecentJobs = visibleCompleted.length > 0\n\n if (!hasActiveJobs && !hasRecentJobs) return null\n\n return (\n <div className={cn('border-b bg-background', className)}>\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={() => setExpanded(!expanded)}\n className=\"h-auto w-full justify-between rounded-none bg-background px-4 py-2 hover:bg-muted\"\n >\n <div className=\"flex items-center gap-2 text-sm\">\n {hasActiveJobs ? (\n <>\n <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n <span>\n {t('progress.activeCount', '{count} operations running', { count: activeJobs.length })}\n </span>\n {activeJobs[0] && (\n <span className=\"text-muted-foreground\">\n \u2014 {activeJobs[0].name}{' '}\n {activeJobs[0].totalCount && activeJobs[0].totalCount > 0\n ? `(${activeJobs[0].progressPercent}%)`\n : `(${activeJobs[0].processedCount.toLocaleString()} ${t('progress.processed', 'processed')})`}\n </span>\n )}\n </>\n ) : (\n <>\n <CheckCircle className=\"h-4 w-4 text-status-success-icon\" />\n <span className=\"text-muted-foreground\">\n {t('progress.recentlyCompleted', '{count} operations completed', { count: visibleCompleted.length })}\n </span>\n </>\n )}\n </div>\n {expanded ? (\n <ChevronUp className=\"h-4 w-4\" />\n ) : (\n <ChevronDown className=\"h-4 w-4\" />\n )}\n </Button>\n\n {expanded && (\n <div className=\"space-y-2 bg-background px-4 pb-3\">\n {activeJobs.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} onCancel={refresh} />\n ))}\n {visibleCompleted.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nfunction ProgressJobCard({ job, t, onCancel }: { job: ProgressJobDto; t: TranslateFn; onCancel?: () => void }) {\n const [cancelling, setCancelling] = React.useState(false)\n\n const handleCancel = async () => {\n if (!job.cancellable || cancelling) return\n setCancelling(true)\n try {\n await apiCall(`/api/progress/jobs/${job.id}`, { method: 'DELETE' })\n onCancel?.()\n } finally {\n setCancelling(false)\n }\n }\n\n const isActive = job.status === 'pending' || job.status === 'running'\n const isFailed = job.status === 'failed'\n const isCompleted = job.status === 'completed'\n\n return (\n <div className={cn(\n 'rounded-md border bg-card p-3',\n isFailed && 'border-destructive/50 bg-destructive/5',\n isCompleted && 'border-status-success-border bg-status-success-bg',\n )}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2\">\n {isActive && <Loader2 className=\"h-4 w-4 animate-spin text-primary flex-shrink-0\" />}\n {isCompleted && <CheckCircle className=\"h-4 w-4 text-status-success-icon flex-shrink-0\" />}\n {isFailed && <XCircle className=\"h-4 w-4 text-destructive flex-shrink-0\" />}\n <span className=\"font-medium truncate\">{job.name}</span>\n </div>\n\n {job.description && (\n <p className=\"text-sm text-muted-foreground mt-1 truncate\">{job.description}</p>\n )}\n\n {isFailed && job.errorMessage && (\n <p className=\"text-sm text-destructive mt-1\">{job.errorMessage}</p>\n )}\n </div>\n\n {isActive && job.cancellable && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleCancel}\n disabled={cancelling}\n className=\"flex-shrink-0\"\n aria-label={t('progress.actions.cancel', 'Cancel')}\n >\n <X className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n\n {isActive && (\n <div className=\"mt-2 space-y-1\">\n {job.totalCount && job.totalCount > 0 ? (\n <Progress value={job.progressPercent} className=\"h-2\" />\n ) : (\n <IndeterminateProgressBar className=\"h-2\" />\n )}\n <div className=\"flex justify-between text-xs text-muted-foreground\">\n <span>\n {job.totalCount\n ? `${job.processedCount.toLocaleString()} / ${job.totalCount.toLocaleString()}`\n : `${job.processedCount.toLocaleString()} ${t('progress.processed', 'processed')}`\n }\n </span>\n {job.etaSeconds != null && job.etaSeconds > 0 && (\n <span>{formatEta(job.etaSeconds, t)}</span>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nfunction IndeterminateProgressBar({ className }: { className?: string }) {\n return (\n <div className={cn('relative w-full overflow-hidden rounded-full bg-secondary', className)}>\n <div className=\"absolute inset-y-0 left-0 w-1/2 animate-pulse rounded-full bg-primary/80\" />\n <div className=\"absolute inset-y-0 right-0 w-1/3 rounded-full bg-primary/10\" />\n </div>\n )\n}\n\nfunction formatEta(seconds: number, t: TranslateFn): string {\n if (seconds < 60) {\n return t('progress.eta.seconds', '{count}s remaining', { count: seconds })\n }\n if (seconds < 3600) {\n const minutes = Math.ceil(seconds / 60)\n return t('progress.eta.minutes', '{count}m remaining', { count: minutes })\n }\n const hours = Math.floor(seconds / 3600)\n const mins = Math.ceil((seconds % 3600) / 60)\n return t('progress.eta.hoursMinutes', '{hours}h {minutes}m remaining', { hours, minutes: mins })\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { Progress } from '../../primitives/progress'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useProgress } from './useProgress'\nimport { useAutoHideCompletedJobs } from './useAutoHideCompletedJobs'\nimport type { ProgressJobDto } from './useProgressPoll'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '../utils/apiCall'\n\nexport type ProgressTopBarProps = {\n className?: string\n t: TranslateFn\n /**\n * How long (ms) to keep successfully completed jobs visible before auto-hiding.\n * Set to `false` or `0` to disable auto-hide. Defaults to 10 000 ms.\n * Failed and cancelled jobs are never auto-hidden.\n */\n completedAutoHideMs?: number | false\n}\n\nexport function ProgressTopBar({ className, t, completedAutoHideMs }: ProgressTopBarProps) {\n const { activeJobs, recentlyCompleted, refresh } = useProgress()\n const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)\n const [expanded, setExpanded] = React.useState(false)\n\n // `om:progress:expanded` is a trivial scalar flag ('true' | 'false') with no\n // schema to evolve, so it is intentionally kept raw rather than wrapped in a\n // versioned envelope (the versioning threshold for structured persisted state\n // lives in `@open-mercato/shared/lib/browser/versionedPreference`).\n React.useEffect(() => {\n const saved = localStorage.getItem('om:progress:expanded')\n if (saved === 'true') setExpanded(true)\n }, [])\n\n React.useEffect(() => {\n localStorage.setItem('om:progress:expanded', String(expanded))\n }, [expanded])\n\n const hasActiveJobs = activeJobs.length > 0\n const hasRecentJobs = visibleCompleted.length > 0\n\n if (!hasActiveJobs && !hasRecentJobs) return null\n\n return (\n <div className={cn('border-b bg-background', className)}>\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={() => setExpanded(!expanded)}\n className=\"h-auto w-full justify-between rounded-none bg-background px-4 py-2 hover:bg-muted\"\n >\n <div className=\"flex items-center gap-2 text-sm\">\n {hasActiveJobs ? (\n <>\n <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n <span>\n {t('progress.activeCount', '{count} operations running', { count: activeJobs.length })}\n </span>\n {activeJobs[0] && (\n <span className=\"text-muted-foreground\">\n \u2014 {activeJobs[0].name}{' '}\n {activeJobs[0].totalCount && activeJobs[0].totalCount > 0\n ? `(${activeJobs[0].progressPercent}%)`\n : `(${activeJobs[0].processedCount.toLocaleString()} ${t('progress.processed', 'processed')})`}\n </span>\n )}\n </>\n ) : (\n <>\n <CheckCircle className=\"h-4 w-4 text-status-success-icon\" />\n <span className=\"text-muted-foreground\">\n {t('progress.recentlyCompleted', '{count} operations completed', { count: visibleCompleted.length })}\n </span>\n </>\n )}\n </div>\n {expanded ? (\n <ChevronUp className=\"h-4 w-4\" />\n ) : (\n <ChevronDown className=\"h-4 w-4\" />\n )}\n </Button>\n\n {expanded && (\n <div className=\"space-y-2 bg-background px-4 pb-3\">\n {activeJobs.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} onCancel={refresh} />\n ))}\n {visibleCompleted.map((job) => (\n <ProgressJobCard key={job.id} job={job} t={t} />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nfunction ProgressJobCard({ job, t, onCancel }: { job: ProgressJobDto; t: TranslateFn; onCancel?: () => void }) {\n const [cancelling, setCancelling] = React.useState(false)\n\n const handleCancel = async () => {\n if (!job.cancellable || cancelling) return\n setCancelling(true)\n try {\n await apiCall(`/api/progress/jobs/${job.id}`, { method: 'DELETE' })\n onCancel?.()\n } finally {\n setCancelling(false)\n }\n }\n\n const isActive = job.status === 'pending' || job.status === 'running'\n const isFailed = job.status === 'failed'\n const isCompleted = job.status === 'completed'\n\n return (\n <div className={cn(\n 'rounded-md border bg-card p-3',\n isFailed && 'border-destructive/50 bg-destructive/5',\n isCompleted && 'border-status-success-border bg-status-success-bg',\n )}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2\">\n {isActive && <Loader2 className=\"h-4 w-4 animate-spin text-primary flex-shrink-0\" />}\n {isCompleted && <CheckCircle className=\"h-4 w-4 text-status-success-icon flex-shrink-0\" />}\n {isFailed && <XCircle className=\"h-4 w-4 text-destructive flex-shrink-0\" />}\n <span className=\"font-medium truncate\">{job.name}</span>\n </div>\n\n {job.description && (\n <p className=\"text-sm text-muted-foreground mt-1 truncate\">{job.description}</p>\n )}\n\n {isFailed && job.errorMessage && (\n <p className=\"text-sm text-destructive mt-1\">{job.errorMessage}</p>\n )}\n </div>\n\n {isActive && job.cancellable && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleCancel}\n disabled={cancelling}\n className=\"flex-shrink-0\"\n aria-label={t('progress.actions.cancel', 'Cancel')}\n >\n <X className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n\n {isActive && (\n <div className=\"mt-2 space-y-1\">\n {job.totalCount && job.totalCount > 0 ? (\n <Progress value={job.progressPercent} className=\"h-2\" />\n ) : (\n <IndeterminateProgressBar className=\"h-2\" />\n )}\n <div className=\"flex justify-between text-xs text-muted-foreground\">\n <span>\n {job.totalCount\n ? `${job.processedCount.toLocaleString()} / ${job.totalCount.toLocaleString()}`\n : `${job.processedCount.toLocaleString()} ${t('progress.processed', 'processed')}`\n }\n </span>\n {job.etaSeconds != null && job.etaSeconds > 0 && (\n <span>{formatEta(job.etaSeconds, t)}</span>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nfunction IndeterminateProgressBar({ className }: { className?: string }) {\n return (\n <div className={cn('relative w-full overflow-hidden rounded-full bg-secondary', className)}>\n <div className=\"absolute inset-y-0 left-0 w-1/2 animate-pulse rounded-full bg-primary/80\" />\n <div className=\"absolute inset-y-0 right-0 w-1/3 rounded-full bg-primary/10\" />\n </div>\n )\n}\n\nfunction formatEta(seconds: number, t: TranslateFn): string {\n if (seconds < 60) {\n return t('progress.eta.seconds', '{count}s remaining', { count: seconds })\n }\n if (seconds < 3600) {\n const minutes = Math.ceil(seconds / 60)\n return t('progress.eta.minutes', '{count}m remaining', { count: minutes })\n }\n const hours = Math.floor(seconds / 3600)\n const mins = Math.ceil((seconds % 3600) / 60)\n return t('progress.eta.hoursMinutes', '{hours}h {minutes}m remaining', { hours, minutes: mins })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6201.1.8ceb502c4b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.6-develop.6201.1.8ceb502c4b",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.6-develop.6201.1.8ceb502c4b",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
package/src/ai/AiChat.tsx
CHANGED
|
@@ -76,6 +76,7 @@ import { useAiChatUpload } from './useAiChatUpload'
|
|
|
76
76
|
import { useAiShortcuts } from './useAiShortcuts'
|
|
77
77
|
import { LoopTracePanel, type LoopTracePanelTrace } from './LoopTracePanel'
|
|
78
78
|
import { AiIcon } from './AiIcon'
|
|
79
|
+
import { readModelPickerValue, writeModelPickerValue } from './modelPickerStorage'
|
|
79
80
|
|
|
80
81
|
// Cap inline previews so we do not blow past localStorage quota (~5MB on most
|
|
81
82
|
// browsers). Images larger than this still upload + send to the LLM as inline
|
|
@@ -83,43 +84,6 @@ import { AiIcon } from './AiIcon'
|
|
|
83
84
|
const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024
|
|
84
85
|
const COMPACT_FOOTER_MAX_WIDTH = 640
|
|
85
86
|
|
|
86
|
-
const MODEL_PICKER_STORAGE_PREFIX = 'om-ai-model-picker:'
|
|
87
|
-
|
|
88
|
-
function readModelPickerValue(agentId: string): ModelPickerValue | null {
|
|
89
|
-
if (typeof window === 'undefined') return null
|
|
90
|
-
try {
|
|
91
|
-
const raw = window.localStorage.getItem(`${MODEL_PICKER_STORAGE_PREFIX}${agentId}`)
|
|
92
|
-
if (!raw) return null
|
|
93
|
-
const parsed = JSON.parse(raw) as unknown
|
|
94
|
-
if (
|
|
95
|
-
parsed &&
|
|
96
|
-
typeof parsed === 'object' &&
|
|
97
|
-
typeof (parsed as Record<string, unknown>).providerId === 'string' &&
|
|
98
|
-
typeof (parsed as Record<string, unknown>).modelId === 'string'
|
|
99
|
-
) {
|
|
100
|
-
const value = parsed as ModelPickerValue
|
|
101
|
-
return { providerId: value.providerId, modelId: value.modelId }
|
|
102
|
-
}
|
|
103
|
-
return null
|
|
104
|
-
} catch {
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function writeModelPickerValue(agentId: string, value: ModelPickerValue | null): void {
|
|
110
|
-
if (typeof window === 'undefined') return
|
|
111
|
-
try {
|
|
112
|
-
const key = `${MODEL_PICKER_STORAGE_PREFIX}${agentId}`
|
|
113
|
-
if (value === null) {
|
|
114
|
-
window.localStorage.removeItem(key)
|
|
115
|
-
} else {
|
|
116
|
-
window.localStorage.setItem(key, JSON.stringify({ providerId: value.providerId, modelId: value.modelId }))
|
|
117
|
-
}
|
|
118
|
-
} catch {
|
|
119
|
-
// Quota exceeded / privacy mode — silently ignore.
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
87
|
interface ModelsApiResponse {
|
|
124
88
|
agentId: string
|
|
125
89
|
allowRuntimeOverride: boolean
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getCurrentOrganizationScope,
|
|
25
25
|
subscribeOrganizationScopeChanged,
|
|
26
26
|
} from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
27
|
+
import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
27
28
|
import {
|
|
28
29
|
createAiServerConversation,
|
|
29
30
|
listAiServerConversations,
|
|
@@ -108,57 +109,62 @@ function makeId(): string {
|
|
|
108
109
|
return `${Date.now().toString(16)}-${rand()}-${rand()}`
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
? Object.fromEntries(
|
|
143
|
-
Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(
|
|
144
|
-
(entry): entry is [string, string] =>
|
|
145
|
-
typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
|
146
|
-
),
|
|
112
|
+
// Versioned-envelope discriminator for the persisted sessions cache. Bump when
|
|
113
|
+
// the stored shape changes incompatibly; legacy bare `{ sessions, activeByAgent }`
|
|
114
|
+
// values are migrated forward on the next write. See
|
|
115
|
+
// `@open-mercato/shared/lib/browser/versionedPreference`.
|
|
116
|
+
const AI_CHAT_SESSIONS_VERSION = 1
|
|
117
|
+
|
|
118
|
+
function isPersistedSessionsShape(value: unknown): value is Partial<AiChatSessionsState> {
|
|
119
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function readPersisted(storageKey: string): AiChatSessionsState {
|
|
123
|
+
const parsed = readVersionedPreference<Partial<AiChatSessionsState> | null>(
|
|
124
|
+
storageKey,
|
|
125
|
+
AI_CHAT_SESSIONS_VERSION,
|
|
126
|
+
(value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value),
|
|
127
|
+
null,
|
|
128
|
+
{ legacyIsValid: (value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value) },
|
|
129
|
+
)
|
|
130
|
+
if (!parsed) return { sessions: [], activeByAgent: {} }
|
|
131
|
+
const sessions = Array.isArray(parsed.sessions)
|
|
132
|
+
? (parsed.sessions as unknown[])
|
|
133
|
+
.filter((entry): entry is AiChatSession => {
|
|
134
|
+
if (!entry || typeof entry !== 'object') return false
|
|
135
|
+
const value = entry as Record<string, unknown>
|
|
136
|
+
return (
|
|
137
|
+
typeof value.id === 'string' &&
|
|
138
|
+
typeof value.agentId === 'string' &&
|
|
139
|
+
typeof value.conversationId === 'string' &&
|
|
140
|
+
typeof value.createdAt === 'number' &&
|
|
141
|
+
typeof value.lastUsedAt === 'number' &&
|
|
142
|
+
(value.status === 'open' || value.status === 'closed')
|
|
147
143
|
)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
144
|
+
})
|
|
145
|
+
.map((entry) => {
|
|
146
|
+
const value = entry as unknown as Record<string, unknown>
|
|
147
|
+
const candidate = value.name
|
|
148
|
+
return {
|
|
149
|
+
...entry,
|
|
150
|
+
name: typeof candidate === 'string' ? candidate : undefined,
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
: []
|
|
154
|
+
const activeByAgent =
|
|
155
|
+
parsed.activeByAgent && typeof parsed.activeByAgent === 'object'
|
|
156
|
+
? Object.fromEntries(
|
|
157
|
+
Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(
|
|
158
|
+
(entry): entry is [string, string] =>
|
|
159
|
+
typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
: {}
|
|
163
|
+
return { sessions, activeByAgent }
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
function writePersisted(storageKey: string, state: AiChatSessionsState): void {
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
window.localStorage.setItem(storageKey, JSON.stringify(state))
|
|
159
|
-
} catch {
|
|
160
|
-
/* quota / privacy mode — drop silently */
|
|
161
|
-
}
|
|
166
|
+
export function writePersisted(storageKey: string, state: AiChatSessionsState): void {
|
|
167
|
+
writeVersionedPreference(storageKey, AI_CHAT_SESSIONS_VERSION, state)
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
function serverConversationToSession(
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { readModelPickerValue, writeModelPickerValue } from '../modelPickerStorage'
|
|
3
|
+
|
|
4
|
+
const PREFIX = 'om-ai-model-picker:'
|
|
5
|
+
|
|
6
|
+
describe('AiChat model-picker storage (versioned envelope)', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
localStorage.clear()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('writes a versioned envelope and reads it back', () => {
|
|
12
|
+
writeModelPickerValue('agent1', { providerId: 'openai', modelId: 'gpt' })
|
|
13
|
+
expect(JSON.parse(localStorage.getItem(`${PREFIX}agent1`)!)).toEqual({
|
|
14
|
+
v: 1,
|
|
15
|
+
data: { providerId: 'openai', modelId: 'gpt' },
|
|
16
|
+
})
|
|
17
|
+
expect(readModelPickerValue('agent1')).toEqual({ providerId: 'openai', modelId: 'gpt' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('migrates a legacy bare (pre-envelope) value on read', () => {
|
|
21
|
+
localStorage.setItem(`${PREFIX}agent2`, JSON.stringify({ providerId: 'anthropic', modelId: 'opus' }))
|
|
22
|
+
expect(readModelPickerValue('agent2')).toEqual({ providerId: 'anthropic', modelId: 'opus' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('discards a version-mismatched envelope', () => {
|
|
26
|
+
localStorage.setItem(`${PREFIX}agent3`, JSON.stringify({ v: 2, data: { providerId: 'x', modelId: 'y' } }))
|
|
27
|
+
expect(readModelPickerValue('agent3')).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns null for malformed data', () => {
|
|
31
|
+
localStorage.setItem(`${PREFIX}agent4`, JSON.stringify({ providerId: 123 }))
|
|
32
|
+
expect(readModelPickerValue('agent4')).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('clears the slot when writing null', () => {
|
|
36
|
+
writeModelPickerValue('agent5', { providerId: 'a', modelId: 'b' })
|
|
37
|
+
writeModelPickerValue('agent5', null)
|
|
38
|
+
expect(localStorage.getItem(`${PREFIX}agent5`)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { readPersisted, writePersisted } from '../AiChatSessions'
|
|
3
|
+
|
|
4
|
+
const KEY = 'om-ai-chat-sessions-v1:t1:o1'
|
|
5
|
+
const session = {
|
|
6
|
+
id: 's1',
|
|
7
|
+
agentId: 'a1',
|
|
8
|
+
conversationId: 'c1',
|
|
9
|
+
createdAt: 1,
|
|
10
|
+
lastUsedAt: 2,
|
|
11
|
+
status: 'open' as const,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('AiChatSessions persisted cache (versioned envelope)', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
localStorage.clear()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('writes a versioned envelope and reads it back', () => {
|
|
20
|
+
const state = { sessions: [session], activeByAgent: { a1: 's1' } }
|
|
21
|
+
writePersisted(KEY, state)
|
|
22
|
+
const stored = JSON.parse(localStorage.getItem(KEY)!)
|
|
23
|
+
expect(stored.v).toBe(1)
|
|
24
|
+
expect(stored.data).toEqual(state)
|
|
25
|
+
expect(readPersisted(KEY)).toEqual(state)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('migrates a legacy bare (pre-envelope) cache on read', () => {
|
|
29
|
+
const legacy = { sessions: [session], activeByAgent: { a1: 's1' } }
|
|
30
|
+
localStorage.setItem(KEY, JSON.stringify(legacy))
|
|
31
|
+
expect(readPersisted(KEY)).toEqual(legacy)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('discards a version-mismatched envelope', () => {
|
|
35
|
+
localStorage.setItem(KEY, JSON.stringify({ v: 99, data: { sessions: [session], activeByAgent: { a1: 's1' } } }))
|
|
36
|
+
expect(readPersisted(KEY)).toEqual({ sessions: [], activeByAgent: {} })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns empty state for missing data', () => {
|
|
40
|
+
expect(readPersisted(KEY)).toEqual({ sessions: [], activeByAgent: {} })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -120,9 +120,10 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
|
|
|
120
120
|
|
|
121
121
|
const scoped = window.localStorage.getItem(scopedKey('T1', 'O1'))
|
|
122
122
|
expect(scoped).not.toBeNull()
|
|
123
|
-
const parsed = JSON.parse(scoped!) as { sessions: Array<{ agentId: string }> }
|
|
124
|
-
expect(parsed.
|
|
125
|
-
expect(parsed.sessions
|
|
123
|
+
const parsed = JSON.parse(scoped!) as { v: number; data: { sessions: Array<{ agentId: string }> } }
|
|
124
|
+
expect(parsed.v).toBe(1)
|
|
125
|
+
expect(parsed.data.sessions).toHaveLength(1)
|
|
126
|
+
expect(parsed.data.sessions[0].agentId).toBe('assistant')
|
|
126
127
|
|
|
127
128
|
expect(window.localStorage.getItem(scopedKey('T2', 'O2'))).toBeNull()
|
|
128
129
|
})
|
|
@@ -185,11 +186,14 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
|
|
|
185
186
|
expect(screen.getByTestId('session-count').textContent).toBe('1')
|
|
186
187
|
})
|
|
187
188
|
|
|
188
|
-
// T1/O1 bucket must
|
|
189
|
+
// T1/O1 bucket must retain its 2 sessions after the swap. The legacy bare
|
|
190
|
+
// value seeded above is migrated forward to the versioned `{ v, data }`
|
|
191
|
+
// envelope on mount, so read the sessions from `data`.
|
|
189
192
|
const t1Raw = window.localStorage.getItem(scopedKey('T1', 'O1'))
|
|
190
193
|
expect(t1Raw).not.toBeNull()
|
|
191
|
-
const t1Parsed = JSON.parse(t1Raw!) as { sessions: unknown[] }
|
|
192
|
-
expect(t1Parsed.
|
|
194
|
+
const t1Parsed = JSON.parse(t1Raw!) as { v: number; data: { sessions: unknown[] } }
|
|
195
|
+
expect(t1Parsed.v).toBe(1)
|
|
196
|
+
expect(t1Parsed.data.sessions).toHaveLength(2)
|
|
193
197
|
})
|
|
194
198
|
|
|
195
199
|
it('refires the server conversation list on scope change', async () => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
2
|
+
import type { ModelPickerValue } from './ModelPicker'
|
|
3
|
+
|
|
4
|
+
const MODEL_PICKER_STORAGE_PREFIX = 'om-ai-model-picker:'
|
|
5
|
+
|
|
6
|
+
// Versioned-envelope discriminator for the persisted model-picker selection.
|
|
7
|
+
// Bump when the stored shape changes incompatibly; legacy bare `{ providerId,
|
|
8
|
+
// modelId }` values are migrated forward on the next write. See
|
|
9
|
+
// `@open-mercato/shared/lib/browser/versionedPreference`.
|
|
10
|
+
const MODEL_PICKER_VERSION = 1
|
|
11
|
+
|
|
12
|
+
function isModelPickerValue(value: unknown): value is ModelPickerValue {
|
|
13
|
+
return (
|
|
14
|
+
!!value &&
|
|
15
|
+
typeof value === 'object' &&
|
|
16
|
+
!Array.isArray(value) &&
|
|
17
|
+
typeof (value as Record<string, unknown>).providerId === 'string' &&
|
|
18
|
+
typeof (value as Record<string, unknown>).modelId === 'string'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readModelPickerValue(agentId: string): ModelPickerValue | null {
|
|
23
|
+
const value = readVersionedPreference<ModelPickerValue | null>(
|
|
24
|
+
`${MODEL_PICKER_STORAGE_PREFIX}${agentId}`,
|
|
25
|
+
MODEL_PICKER_VERSION,
|
|
26
|
+
(candidate): candidate is ModelPickerValue | null => isModelPickerValue(candidate),
|
|
27
|
+
null,
|
|
28
|
+
{ legacyIsValid: (candidate): candidate is ModelPickerValue | null => isModelPickerValue(candidate) },
|
|
29
|
+
)
|
|
30
|
+
return value ? { providerId: value.providerId, modelId: value.modelId } : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeModelPickerValue(agentId: string, value: ModelPickerValue | null): void {
|
|
34
|
+
const key = `${MODEL_PICKER_STORAGE_PREFIX}${agentId}`
|
|
35
|
+
if (value === null) {
|
|
36
|
+
clearVersionedPreference(key)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
writeVersionedPreference(key, MODEL_PICKER_VERSION, { providerId: value.providerId, modelId: value.modelId })
|
|
40
|
+
}
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -195,6 +195,13 @@ const SESSION_UPDATED_EVENT = 'om-ai-chat-session-updated'
|
|
|
195
195
|
const SESSION_STREAM_STATE_EVENT = 'om-ai-chat-session-stream-state'
|
|
196
196
|
const activeSessionStreams = new Set<string>()
|
|
197
197
|
|
|
198
|
+
// This slot already implements its own versioned, migratable shape: the inline
|
|
199
|
+
// `v` discriminator is checked on read and the value is discarded on mismatch
|
|
200
|
+
// (see `readPersistedSession`). It is intentionally NOT migrated to the generic
|
|
201
|
+
// `{ v, data }` envelope from `@open-mercato/shared/lib/browser/versionedPreference`
|
|
202
|
+
// — doing so would change the on-disk format and discard users' in-progress
|
|
203
|
+
// sessions, and the write path needs bespoke transient-blob-URL stripping that
|
|
204
|
+
// the generic helper does not express.
|
|
198
205
|
interface PersistedAiChatSession {
|
|
199
206
|
v: number
|
|
200
207
|
conversationId: string
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
|
|
|
31
31
|
import { PartialIndexBanner } from './indexes/PartialIndexBanner'
|
|
32
32
|
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
33
33
|
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
34
|
+
import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
34
35
|
import { cloneSidebarGroups } from './sidebar/customization-helpers'
|
|
35
36
|
import type { SectionNavGroup } from './section-page/types'
|
|
36
37
|
import { InjectionSpot } from './injection/InjectionSpot'
|
|
@@ -59,6 +60,25 @@ import {
|
|
|
59
60
|
GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID,
|
|
60
61
|
} from './injection/spotIds'
|
|
61
62
|
|
|
63
|
+
// Versioned-envelope discriminator for the persisted sidebar open/closed group
|
|
64
|
+
// map. This is a structured value (a record), so it carries a version so future
|
|
65
|
+
// shape changes can migrate or safely discard stale data; legacy bare
|
|
66
|
+
// `Record<string, boolean>` values are migrated forward on the next write. The
|
|
67
|
+
// neighbouring `om:sidebarCollapsed` / `om:progress:expanded` flags are trivial
|
|
68
|
+
// scalar booleans and deliberately stay raw (see their write sites). See
|
|
69
|
+
// `@open-mercato/shared/lib/browser/versionedPreference`.
|
|
70
|
+
const SIDEBAR_OPEN_GROUPS_KEY = 'om:sidebarOpenGroups'
|
|
71
|
+
const SIDEBAR_OPEN_GROUPS_VERSION = 1
|
|
72
|
+
|
|
73
|
+
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
|
74
|
+
return (
|
|
75
|
+
!!value &&
|
|
76
|
+
typeof value === 'object' &&
|
|
77
|
+
!Array.isArray(value) &&
|
|
78
|
+
Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'boolean')
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
export type ShellLogo = {
|
|
63
83
|
src: string
|
|
64
84
|
alt?: string
|
|
@@ -605,22 +625,23 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
605
625
|
}, [mobileOpen])
|
|
606
626
|
|
|
607
627
|
React.useEffect(() => {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
628
|
+
const parsed = readVersionedPreference<Record<string, boolean>>(
|
|
629
|
+
SIDEBAR_OPEN_GROUPS_KEY,
|
|
630
|
+
SIDEBAR_OPEN_GROUPS_VERSION,
|
|
631
|
+
isBooleanRecord,
|
|
632
|
+
{},
|
|
633
|
+
{ legacyIsValid: isBooleanRecord },
|
|
634
|
+
)
|
|
635
|
+
if (Object.keys(parsed).length === 0) return
|
|
636
|
+
setOpenGroups((prev) => {
|
|
637
|
+
const next = { ...prev }
|
|
638
|
+
for (const group of resolvedGroups) {
|
|
639
|
+
const key = resolveGroupKey(group)
|
|
640
|
+
if (key in parsed) next[key] = !!parsed[key]
|
|
641
|
+
else if (group.name in parsed) next[key] = !!parsed[group.name]
|
|
642
|
+
}
|
|
643
|
+
return next
|
|
644
|
+
})
|
|
624
645
|
}, [resolvedGroups])
|
|
625
646
|
|
|
626
647
|
const toggleGroup = (groupId: string) => setOpenGroups((prev) => ({ ...prev, [groupId]: prev[groupId] === false }))
|
|
@@ -633,6 +654,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
633
654
|
// private/incognito mode (storage blocked) or when cookies are disabled —
|
|
634
655
|
// the persisted preference is purely a UX nice-to-have, never functional, so
|
|
635
656
|
// swallow the failure and let the component fall back to the default state.
|
|
657
|
+
// This is a trivial scalar flag ('1' | '0') with no schema to evolve, so it is
|
|
658
|
+
// intentionally kept raw rather than wrapped in a versioned envelope (the
|
|
659
|
+
// versioning threshold lives in `@open-mercato/shared/lib/browser/versionedPreference`).
|
|
636
660
|
React.useEffect(() => {
|
|
637
661
|
try { localStorage.setItem('om:sidebarCollapsed', collapsed ? '1' : '0') } catch { /* localStorage blocked (private mode) — non-critical */ }
|
|
638
662
|
try {
|
|
@@ -659,7 +683,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
659
683
|
previousSidebarModeRef.current = sidebarMode
|
|
660
684
|
}, [sidebarMode, collapsed])
|
|
661
685
|
React.useEffect(() => {
|
|
662
|
-
|
|
686
|
+
writeVersionedPreference(SIDEBAR_OPEN_GROUPS_KEY, SIDEBAR_OPEN_GROUPS_VERSION, openGroups)
|
|
663
687
|
}, [openGroups])
|
|
664
688
|
|
|
665
689
|
// Ensure current route's group is expanded on load
|