@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.
@@ -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 apiCall(`/api/notifications/${id}/read`, { method: "PUT" });
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 apiCall(`/api/notifications/${id}/dismiss`, { method: "PUT" });
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 apiCall(`/api/notifications/${dismissUndo.notification.id}/restore`, {
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 apiCall("/api/notifications/mark-all-read", { method: "PUT" });
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 apiCall(`/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 apiCall(`/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 apiCall(`/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 apiCall('/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": "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",
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": "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",
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": "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",
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.6184.1.b7e55f8d61",
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.6184.1.b7e55f8d61",
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.6184.1.b7e55f8d61",
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
- function readPersisted(storageKey: string): AiChatSessionsState {
112
- if (typeof window === 'undefined') return { sessions: [], activeByAgent: {} }
113
- try {
114
- const raw = window.localStorage.getItem(storageKey)
115
- if (!raw) return { sessions: [], activeByAgent: {} }
116
- const parsed = JSON.parse(raw) as Partial<AiChatSessionsState> | null
117
- const sessions = Array.isArray(parsed?.sessions)
118
- ? (parsed!.sessions as unknown[])
119
- .filter((entry): entry is AiChatSession => {
120
- if (!entry || typeof entry !== 'object') return false
121
- const value = entry as Record<string, unknown>
122
- return (
123
- typeof value.id === 'string' &&
124
- typeof value.agentId === 'string' &&
125
- typeof value.conversationId === 'string' &&
126
- typeof value.createdAt === 'number' &&
127
- typeof value.lastUsedAt === 'number' &&
128
- (value.status === 'open' || value.status === 'closed')
129
- )
130
- })
131
- .map((entry) => {
132
- const value = entry as unknown as Record<string, unknown>
133
- const candidate = value.name
134
- return {
135
- ...entry,
136
- name: typeof candidate === 'string' ? candidate : undefined,
137
- }
138
- })
139
- : []
140
- const activeByAgent =
141
- parsed?.activeByAgent && typeof parsed.activeByAgent === 'object'
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
- return { sessions, activeByAgent }
150
- } catch {
151
- return { sessions: [], activeByAgent: {} }
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
- if (typeof window === 'undefined') return
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.sessions).toHaveLength(1)
125
- expect(parsed.sessions[0].agentId).toBe('assistant')
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 remain untouched after the swap.
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.sessions).toHaveLength(2)
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
+ }
@@ -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
@@ -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
- try {
609
- const savedOpen = typeof window !== 'undefined' ? localStorage.getItem('om:sidebarOpenGroups') : null
610
- if (!savedOpen) return
611
- const parsed = JSON.parse(savedOpen) as Record<string, boolean>
612
- setOpenGroups((prev) => {
613
- const next = { ...prev }
614
- for (const group of resolvedGroups) {
615
- const key = resolveGroupKey(group)
616
- if (key in parsed) next[key] = !!parsed[key]
617
- else if (group.name in parsed) next[key] = !!parsed[group.name]
618
- }
619
- return next
620
- })
621
- } catch {
622
- // ignore localStorage errors to avoid breaking hydration
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
- try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch { /* localStorage blocked (private mode) — non-critical */ }
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