@object-ui/app-shell 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +10 -1
  3. package/dist/console/AppContent.js +24 -2
  4. package/dist/console/ai/AiChatPage.d.ts +8 -0
  5. package/dist/console/ai/AiChatPage.js +188 -0
  6. package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
  7. package/dist/console/ai/ConversationsSidebar.js +111 -0
  8. package/dist/console/auth/LoginPage.js +19 -2
  9. package/dist/console/auth/RegisterPage.js +30 -1
  10. package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
  11. package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
  12. package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
  13. package/dist/console/marketplace/MarketplacePage.js +55 -18
  14. package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
  15. package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
  16. package/dist/console/marketplace/usePackageL10n.js +110 -0
  17. package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
  18. package/dist/console/organizations/OrganizationsPage.js +24 -3
  19. package/dist/context/FavoritesProvider.d.ts +40 -2
  20. package/dist/context/FavoritesProvider.js +201 -20
  21. package/dist/hooks/index.d.ts +1 -0
  22. package/dist/hooks/index.js +1 -0
  23. package/dist/hooks/useChatConversation.d.ts +7 -0
  24. package/dist/hooks/useChatConversation.js +37 -5
  25. package/dist/hooks/useConversationList.d.ts +25 -0
  26. package/dist/hooks/useConversationList.js +131 -0
  27. package/dist/hooks/useNavPins.d.ts +11 -4
  28. package/dist/hooks/useNavPins.js +104 -53
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +14 -0
  31. package/dist/layout/AppHeader.js +2 -2
  32. package/dist/layout/AppSidebar.js +20 -1
  33. package/dist/layout/UnifiedSidebar.js +1 -1
  34. package/dist/providers/ExpressionProvider.d.ts +11 -1
  35. package/dist/providers/ExpressionProvider.js +11 -6
  36. package/dist/services/builtinComponents.d.ts +1 -0
  37. package/dist/services/builtinComponents.js +166 -0
  38. package/dist/services/componentRegistry.d.ts +63 -0
  39. package/dist/services/componentRegistry.js +36 -0
  40. package/dist/views/ComponentNavView.d.ts +6 -0
  41. package/dist/views/ComponentNavView.js +26 -0
  42. package/dist/views/RecordDetailView.js +66 -6
  43. package/dist/views/RecordFormPage.js +15 -3
  44. package/dist/views/SearchResultsPage.js +4 -0
  45. package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +58 -0
  46. package/dist/views/metadata-admin/DesignerEditorWrapper.js +140 -0
  47. package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
  48. package/dist/views/metadata-admin/DirectoryPage.js +135 -0
  49. package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
  50. package/dist/views/metadata-admin/LayeredDiff.js +26 -0
  51. package/dist/views/metadata-admin/PageShell.d.ts +34 -0
  52. package/dist/views/metadata-admin/PageShell.js +33 -0
  53. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
  54. package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
  55. package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
  56. package/dist/views/metadata-admin/QuickFind.js +152 -0
  57. package/dist/views/metadata-admin/ResourceEditPage.d.ts +7 -0
  58. package/dist/views/metadata-admin/ResourceEditPage.js +256 -0
  59. package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
  60. package/dist/views/metadata-admin/ResourceHistoryPage.js +97 -0
  61. package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
  62. package/dist/views/metadata-admin/ResourceListPage.js +144 -0
  63. package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
  64. package/dist/views/metadata-admin/ResourceRouter.js +47 -0
  65. package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
  66. package/dist/views/metadata-admin/SchemaForm.js +556 -0
  67. package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
  68. package/dist/views/metadata-admin/default-schemas.js +207 -0
  69. package/dist/views/metadata-admin/i18n.d.ts +33 -0
  70. package/dist/views/metadata-admin/i18n.js +303 -0
  71. package/dist/views/metadata-admin/index.d.ts +31 -0
  72. package/dist/views/metadata-admin/index.js +33 -0
  73. package/dist/views/metadata-admin/predicate.d.ts +31 -0
  74. package/dist/views/metadata-admin/predicate.js +150 -0
  75. package/dist/views/metadata-admin/registry.d.ts +125 -0
  76. package/dist/views/metadata-admin/registry.js +48 -0
  77. package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
  78. package/dist/views/metadata-admin/useMetadata.js +96 -0
  79. package/dist/views/metadata-admin/widgets.d.ts +68 -0
  80. package/dist/views/metadata-admin/widgets.js +287 -0
  81. package/package.json +27 -26
@@ -24,12 +24,34 @@ function nameToSlug(name) {
24
24
  }
25
25
  export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
26
26
  const { t } = useObjectTranslation();
27
- const { createOrganization } = useAuth();
27
+ const { createOrganization, getAuthConfig } = useAuth();
28
28
  const [name, setName] = useState('');
29
29
  const [slug, setSlug] = useState('');
30
30
  const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
31
31
  const [isSubmitting, setIsSubmitting] = useState(false);
32
32
  const [error, setError] = useState(null);
33
+ // Defense-in-depth: the toolbar button that opens this dialog is already
34
+ // hidden when `multiOrgEnabled === false`, but if a future caller opens the
35
+ // dialog by another path we still want to fail fast with a friendly message
36
+ // instead of bouncing off the server's FORBIDDEN.
37
+ const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
38
+ useEffect(() => {
39
+ if (!open)
40
+ return;
41
+ let cancelled = false;
42
+ getAuthConfig()
43
+ .then((cfg) => {
44
+ if (cancelled)
45
+ return;
46
+ setMultiOrgDisabled(cfg?.features?.multiOrgEnabled === false);
47
+ })
48
+ .catch(() => {
49
+ /* leave default — server still enforces */
50
+ });
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, [open, getAuthConfig]);
33
55
  // Auto-generate slug from name (unless manually edited)
34
56
  useEffect(() => {
35
57
  if (!slugManuallyEdited) {
@@ -49,6 +71,12 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
49
71
  e.preventDefault();
50
72
  if (!name.trim() || !slug.trim())
51
73
  return;
74
+ if (multiOrgDisabled) {
75
+ setError(t('workspace.multiOrgDisabled', {
76
+ defaultValue: 'Creating new organizations is disabled on this instance.',
77
+ }));
78
+ return;
79
+ }
52
80
  setIsSubmitting(true);
53
81
  setError(null);
54
82
  try {
@@ -26,10 +26,31 @@ function getOrgInitials(name) {
26
26
  export function OrganizationsPage() {
27
27
  const { t } = useObjectTranslation();
28
28
  const navigate = useNavigate();
29
- const { organizations, activeOrganization, isOrganizationsLoading, switchOrganization, } = useAuth();
29
+ const { organizations, activeOrganization, isOrganizationsLoading, switchOrganization, getAuthConfig, } = useAuth();
30
30
  const [query, setQuery] = useState('');
31
31
  const [isCreateOpen, setIsCreateOpen] = useState(false);
32
32
  const [switchingId, setSwitchingId] = useState(null);
33
+ // `multiOrgEnabled === false` ⇒ server-side `beforeCreateOrganization` hook
34
+ // blocks creation. Mirror that in the UI so users don't see a button that
35
+ // only ever fails. Default to allowing creation until we've heard back so
36
+ // we don't briefly hide the button on slow networks.
37
+ const [canCreateOrg, setCanCreateOrg] = useState(true);
38
+ useEffect(() => {
39
+ let cancelled = false;
40
+ getAuthConfig()
41
+ .then((cfg) => {
42
+ if (cancelled)
43
+ return;
44
+ if (cfg?.features?.multiOrgEnabled === false)
45
+ setCanCreateOrg(false);
46
+ })
47
+ .catch(() => {
48
+ /* leave default — server will still enforce */
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [getAuthConfig]);
33
54
  const orgList = organizations ?? [];
34
55
  const filtered = useMemo(() => {
35
56
  const q = query.trim().toLowerCase();
@@ -87,9 +108,9 @@ export function OrganizationsPage() {
87
108
  defaultValue: 'Select an organization to continue, or create a new one.',
88
109
  }) })] }), _jsxs("div", { className: "mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "relative w-full sm:max-w-sm", children: [_jsx(Search, { className: "pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('organizations.searchPlaceholder', {
89
110
  defaultValue: 'Search for an organization',
90
- }), className: "pl-9", "data-testid": "organizations-search" })] }), _jsxs(Button, { onClick: () => setIsCreateOpen(true), "data-testid": "organizations-new", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] })] }), orgList.length === 0 ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('organizations.emptyTitle', { defaultValue: 'No organizations yet' }) }), _jsx(EmptyDescription, { children: t('organizations.emptyDescription', {
111
+ }), className: "pl-9", "data-testid": "organizations-search" })] }), canCreateOrg && (_jsxs(Button, { onClick: () => setIsCreateOpen(true), "data-testid": "organizations-new", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] }))] }), orgList.length === 0 ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('organizations.emptyTitle', { defaultValue: 'No organizations yet' }) }), _jsx(EmptyDescription, { children: t('organizations.emptyDescription', {
91
112
  defaultValue: 'Create your first organization to get started.',
92
- }) }), _jsxs(Button, { className: "mt-6", onClick: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] })] })) : filtered.length === 0 ? (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: t('organizations.noMatches', {
113
+ }) }), canCreateOrg && (_jsxs(Button, { className: "mt-6", onClick: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] }))] })) : filtered.length === 0 ? (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: t('organizations.noMatches', {
93
114
  defaultValue: 'No organizations match your search.',
94
115
  }) })) : (_jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: filtered.map((org) => {
95
116
  const isActive = org.id === activeOrganization?.id;
@@ -18,13 +18,35 @@
18
18
  */
19
19
  import { type ReactNode } from 'react';
20
20
  export interface FavoriteItem {
21
- /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
21
+ /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" or "nav:<navId>" */
22
22
  id: string;
23
23
  label: string;
24
24
  href: string;
25
- type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
25
+ /**
26
+ * Item kind.
27
+ *
28
+ * - `object` / `dashboard` / `page` / `report` / `record` — content favorites
29
+ * surfaced on Home (Starred Apps) and the sidebar Favorites section.
30
+ * - `nav` — a sidebar navigation entry promoted to the "Pinned" position via
31
+ * the in-tree pin toggle (see `useNavPins`). Excluded from Home/Starred and
32
+ * from the generic sidebar Favorites list so it doesn't render twice.
33
+ */
34
+ type: 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'nav';
26
35
  /** ISO timestamp of when the item was favorited */
27
36
  favoritedAt: string;
37
+ /**
38
+ * When true, the item is pinned to the top of the sidebar (Pinned section).
39
+ * `nav`-type items are always pinned by construction; other types may also
40
+ * carry this flag if the user explicitly promotes a content favorite — that
41
+ * behaviour is reserved for a future iteration.
42
+ */
43
+ pinned?: boolean;
44
+ /**
45
+ * For `type === 'nav'` items: the originating `NavigationItem.id` in the
46
+ * application's nav tree. Used by `useNavPins.applyPins` to flag the live
47
+ * tree node without leaking the synthesized favorite into other UIs.
48
+ */
49
+ navId?: string;
28
50
  }
29
51
  interface FavoritesContextValue {
30
52
  favorites: FavoriteItem[];
@@ -40,6 +62,22 @@ interface FavoritesContextValue {
40
62
  * loaded) get rewritten transparently on the next visit.
41
63
  */
42
64
  refreshLabel: (id: string, label: string) => void;
65
+ /**
66
+ * Flip the `pinned` flag on an existing favorite. No-op if the id is not
67
+ * present. Used by `useNavPins` for nav-tree pin toggles backed by the
68
+ * unified favorites store (same backend sync channel).
69
+ */
70
+ setPinned: (id: string, pinned: boolean) => void;
71
+ /**
72
+ * Returns true when a favorite with the given id is currently `pinned`.
73
+ * Use `isFavorite` for membership-only queries.
74
+ */
75
+ isPinned: (id: string) => boolean;
76
+ /**
77
+ * Set of NavigationItem ids that are currently pinned via a `type === 'nav'`
78
+ * favorite. Memoised; safe to use as a dependency.
79
+ */
80
+ pinnedNavIds: Set<string>;
43
81
  }
44
82
  interface FavoritesProviderProps {
45
83
  children: ReactNode;
@@ -24,7 +24,55 @@ import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, }
24
24
  // Storage helpers
25
25
  // ---------------------------------------------------------------------------
26
26
  const STORAGE_BASE_KEY = 'objectui-favorites';
27
+ /** Legacy key used by the standalone `useNavPins` hook before it was merged
28
+ * into Favorites. Migrated once at provider mount, then deleted. */
29
+ const LEGACY_NAV_PINS_KEY = 'objectui-nav-pins';
30
+ /** Cap for user-visible content favorites (object/dashboard/page/report/record). */
27
31
  const MAX_FAVORITES = 20;
32
+ /** Independent cap for nav-pin favorites — they live in a separate bucket. */
33
+ const MAX_NAV_PINS = 20;
34
+ /**
35
+ * Enforce per-bucket caps while preserving order within each bucket. Content
36
+ * favorites (`type !== 'nav'`) cap at `MAX_FAVORITES`; nav-pin favorites
37
+ * (`type === 'nav'`) cap at `MAX_NAV_PINS`. Returns the original array when
38
+ * neither bucket overflows so memoized consumers aren't invalidated.
39
+ */
40
+ function capByBucket(items) {
41
+ let contentCount = 0;
42
+ let navCount = 0;
43
+ let overflowed = false;
44
+ for (const it of items) {
45
+ if (it.type === 'nav') {
46
+ navCount++;
47
+ if (navCount > MAX_NAV_PINS)
48
+ overflowed = true;
49
+ }
50
+ else {
51
+ contentCount++;
52
+ if (contentCount > MAX_FAVORITES)
53
+ overflowed = true;
54
+ }
55
+ }
56
+ if (!overflowed)
57
+ return items;
58
+ const result = [];
59
+ let c = 0, n = 0;
60
+ for (const it of items) {
61
+ if (it.type === 'nav') {
62
+ if (n < MAX_NAV_PINS) {
63
+ result.push(it);
64
+ n++;
65
+ }
66
+ }
67
+ else {
68
+ if (c < MAX_FAVORITES) {
69
+ result.push(it);
70
+ c++;
71
+ }
72
+ }
73
+ }
74
+ return result;
75
+ }
28
76
  function loadFavorites(userId) {
29
77
  try {
30
78
  const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
@@ -42,6 +90,69 @@ function saveFavorites(items, userId) {
42
90
  // Storage full — silently ignore
43
91
  }
44
92
  }
93
+ /**
94
+ * One-shot migration of legacy `objectui-nav-pins` (a `string[]` of
95
+ * NavigationItem ids) into the unified favorites list as `type: 'nav'`
96
+ * entries. The label/href fields are placeholders because at migration time
97
+ * we only have the raw nav id — the live nav tree resolves the real label
98
+ * when it renders the Pinned section (see `useNavPins.applyPins`).
99
+ *
100
+ * Idempotent: runs only when the legacy key exists. Once consumed, the
101
+ * legacy key is removed so the next launch is a no-op. Existing nav-pinned
102
+ * favorites (e.g. on a subsequent device after backend hydration) are not
103
+ * duplicated.
104
+ */
105
+ function migrateLegacyNavPins(current) {
106
+ let raw;
107
+ try {
108
+ raw = localStorage.getItem(LEGACY_NAV_PINS_KEY);
109
+ }
110
+ catch {
111
+ return { migrated: current, didMigrate: false };
112
+ }
113
+ if (!raw)
114
+ return { migrated: current, didMigrate: false };
115
+ let legacyIds = [];
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ if (Array.isArray(parsed)) {
119
+ legacyIds = parsed.filter((id) => typeof id === 'string');
120
+ }
121
+ }
122
+ catch {
123
+ /* fall through — treat as empty */
124
+ }
125
+ // Always clear the legacy key, even on empty/corrupt payload, so we never
126
+ // re-enter this branch on the next mount.
127
+ try {
128
+ localStorage.removeItem(LEGACY_NAV_PINS_KEY);
129
+ }
130
+ catch { /* noop */ }
131
+ if (legacyIds.length === 0)
132
+ return { migrated: current, didMigrate: false };
133
+ const existingNavIds = new Set(current.filter(f => f.type === 'nav' && f.navId).map(f => f.navId));
134
+ const now = new Date().toISOString();
135
+ const additions = [];
136
+ for (const navId of legacyIds) {
137
+ if (existingNavIds.has(navId))
138
+ continue;
139
+ additions.push({
140
+ id: `nav:${navId}`,
141
+ label: navId, // placeholder — sidebar uses live nav tree label
142
+ href: '', // placeholder — sidebar uses live nav tree href
143
+ type: 'nav',
144
+ navId,
145
+ pinned: true,
146
+ favoritedAt: now,
147
+ });
148
+ }
149
+ if (additions.length === 0)
150
+ return { migrated: current, didMigrate: true };
151
+ // Nav pins do not count against the user-visible favorites cap — they are
152
+ // a separate logical bucket. Append after content favorites so a user with
153
+ // 20 stars keeps them all visible on Home/Starred.
154
+ return { migrated: [...current, ...additions], didMigrate: true };
155
+ }
45
156
  // ---------------------------------------------------------------------------
46
157
  // Context
47
158
  // ---------------------------------------------------------------------------
@@ -61,12 +172,15 @@ export function FavoritesProvider({ children }) {
61
172
  // so this never echoes our own mutations.
62
173
  useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
63
174
  setFavorites(Array.isArray(value)
64
- ? value.filter(it => it && typeof it.id === 'string').slice(0, MAX_FAVORITES)
175
+ ? capByBucket(value.filter(it => it && typeof it.id === 'string'))
65
176
  : []);
66
177
  });
67
178
  // Hydrate from backend whenever an adapter is attached (or user changes).
68
- // Backend wins on conflict; we then write through to localStorage so the
69
- // next reload is instant.
179
+ // Backend is the baseline, but we *merge in* any locally-known nav-pin
180
+ // favorites the backend doesn't have yet — this preserves pins added
181
+ // offline / pre-auth, and pins recovered from the legacy `objectui-nav-pins`
182
+ // key by the offline migration effect below. Legacy migration also runs
183
+ // here as a safety net if the offline effect hasn't yet fired in time.
70
184
  const hydrationToken = useRef(0);
71
185
  useEffect(() => {
72
186
  if (!adapter)
@@ -79,11 +193,28 @@ export function FavoritesProvider({ children }) {
79
193
  if (cancelled || token !== hydrationToken.current)
80
194
  return;
81
195
  // Defensive sanitize: drop unparseable shapes, enforce cap.
82
- const sane = (Array.isArray(remote) ? remote : [])
83
- .filter(it => it && typeof it.id === 'string')
84
- .slice(0, MAX_FAVORITES);
85
- setFavorites(sane);
86
- saveFavorites(sane, userId);
196
+ const sane = capByBucket((Array.isArray(remote) ? remote : []).filter(it => it && typeof it.id === 'string'));
197
+ // Re-read the local snapshot it may contain nav-pin favorites that
198
+ // were added (or migrated) before the adapter arrived. Without this
199
+ // merge, signing in would silently wipe a user's offline pins.
200
+ const local = loadFavorites(userId);
201
+ const remoteNavIds = new Set(sane.filter(f => f.type === 'nav' && f.navId).map(f => f.navId));
202
+ const localNavExtras = local.filter(f => f.type === 'nav' && f.navId && !remoteNavIds.has(f.navId));
203
+ let merged = sane;
204
+ let pushBack = false;
205
+ if (localNavExtras.length > 0) {
206
+ merged = capByBucket([...sane, ...localNavExtras]);
207
+ pushBack = true;
208
+ }
209
+ const { migrated, didMigrate } = migrateLegacyNavPins(merged);
210
+ if (didMigrate) {
211
+ merged = capByBucket(migrated);
212
+ pushBack = true;
213
+ }
214
+ setFavorites(merged);
215
+ saveFavorites(merged, userId);
216
+ if (pushBack)
217
+ flusher.schedule(merged);
87
218
  }
88
219
  catch {
89
220
  // Keep localStorage state — adapter degrade-to-noop is acceptable.
@@ -93,6 +224,22 @@ export function FavoritesProvider({ children }) {
93
224
  cancelled = true;
94
225
  };
95
226
  }, [adapter, userId]);
227
+ // Offline / pre-auth migration: when there is no adapter to push to, fold
228
+ // legacy nav-pins into the localStorage baseline immediately so the very
229
+ // first render after upgrade already includes them. If an adapter later
230
+ // attaches, the hydrate effect above merges these into the backend.
231
+ useEffect(() => {
232
+ if (adapter)
233
+ return; // adapter path handles migration above
234
+ setFavorites(prev => {
235
+ const { migrated, didMigrate } = migrateLegacyNavPins(prev);
236
+ if (!didMigrate)
237
+ return prev;
238
+ const next = capByBucket(migrated);
239
+ saveFavorites(next, userId);
240
+ return next;
241
+ });
242
+ }, [adapter, userId]);
96
243
  // Debounced write-through to backend.
97
244
  const flusher = useMemo(() => createDebouncedFlush(async (items) => {
98
245
  if (adapter)
@@ -109,10 +256,10 @@ export function FavoritesProvider({ children }) {
109
256
  setFavorites(prev => {
110
257
  if (prev.some(f => f.id === item.id))
111
258
  return prev;
112
- const updated = [
259
+ const updated = capByBucket([
113
260
  { ...item, favoritedAt: new Date().toISOString() },
114
261
  ...prev,
115
- ].slice(0, MAX_FAVORITES);
262
+ ]);
116
263
  commit(updated);
117
264
  return updated;
118
265
  });
@@ -129,7 +276,10 @@ export function FavoritesProvider({ children }) {
129
276
  const exists = prev.some(f => f.id === item.id);
130
277
  const updated = exists
131
278
  ? prev.filter(f => f.id !== item.id)
132
- : [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(0, MAX_FAVORITES);
279
+ : capByBucket([
280
+ { ...item, favoritedAt: new Date().toISOString() },
281
+ ...prev,
282
+ ]);
133
283
  commit(updated);
134
284
  return updated;
135
285
  });
@@ -156,15 +306,43 @@ export function FavoritesProvider({ children }) {
156
306
  return updated;
157
307
  });
158
308
  }, [commit]);
159
- const value = useMemo(() => ({
160
- favorites,
161
- addFavorite,
162
- removeFavorite,
163
- toggleFavorite,
164
- isFavorite: (id) => favorites.some(f => f.id === id),
165
- clearFavorites,
166
- refreshLabel,
167
- }), [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel]);
309
+ const setPinned = useCallback((id, pinned) => {
310
+ setFavorites(prev => {
311
+ let changed = false;
312
+ const updated = prev.map(f => {
313
+ if (f.id !== id)
314
+ return f;
315
+ const cur = !!f.pinned;
316
+ if (cur === pinned)
317
+ return f;
318
+ changed = true;
319
+ return { ...f, pinned };
320
+ });
321
+ if (!changed)
322
+ return prev;
323
+ commit(updated);
324
+ return updated;
325
+ });
326
+ }, [commit]);
327
+ const value = useMemo(() => {
328
+ const pinnedNavIds = new Set();
329
+ for (const f of favorites) {
330
+ if (f.pinned && f.type === 'nav' && f.navId)
331
+ pinnedNavIds.add(f.navId);
332
+ }
333
+ return {
334
+ favorites,
335
+ addFavorite,
336
+ removeFavorite,
337
+ toggleFavorite,
338
+ isFavorite: (id) => favorites.some(f => f.id === id),
339
+ clearFavorites,
340
+ refreshLabel,
341
+ setPinned,
342
+ isPinned: (id) => favorites.some(f => f.id === id && !!f.pinned),
343
+ pinnedNavIds,
344
+ };
345
+ }, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned]);
168
346
  return (_jsx(FavoritesContext.Provider, { value: value, children: children }));
169
347
  }
170
348
  // ---------------------------------------------------------------------------
@@ -189,6 +367,9 @@ export function useFavorites() {
189
367
  isFavorite: () => false,
190
368
  clearFavorites: () => { },
191
369
  refreshLabel: () => { },
370
+ setPinned: () => { },
371
+ isPinned: () => false,
372
+ pinnedNavIds: new Set(),
192
373
  };
193
374
  }
194
375
  return ctx;
@@ -8,3 +8,4 @@ export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
9
  export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
10
10
  export { useChatConversation, type HydratedUIMessage, type UseChatConversationOptions, type UseChatConversationReturn, } from './useChatConversation';
11
+ export { useConversationList, type ConversationSummary, type UseConversationListOptions, type UseConversationListReturn, } from './useConversationList';
@@ -8,3 +8,4 @@ export { useRecordApprovals } from './useRecordApprovals';
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
9
  export { useTrackRouteAsRecent } from './useTrackRouteAsRecent';
10
10
  export { useChatConversation, } from './useChatConversation';
11
+ export { useConversationList, } from './useConversationList';
@@ -20,6 +20,13 @@ export interface UseChatConversationOptions {
20
20
  * `${apiBase}/conversations[/...]`. Required.
21
21
  */
22
22
  apiBase: string;
23
+ /**
24
+ * Explicit conversation id to hydrate. When provided the cache is bypassed
25
+ * and the hook fetches this conversation directly; falls back to creating a
26
+ * fresh one if the server returns 404/403. When omitted the hook keeps its
27
+ * original cache-first / create-on-miss behaviour.
28
+ */
29
+ activeId?: string;
23
30
  }
24
31
  export interface UseChatConversationReturn {
25
32
  conversationId: string | undefined;
@@ -100,11 +100,15 @@ async function deleteConversation(apiBase, id) {
100
100
  });
101
101
  }
102
102
  export function useChatConversation(options) {
103
- const { userId, scope, apiBase } = options;
103
+ const { userId, scope, apiBase, activeId } = options;
104
104
  const [conversationId, setConversationId] = useState(undefined);
105
105
  const [initialMessages, setInitialMessages] = useState([]);
106
106
  const [isLoading, setIsLoading] = useState(Boolean(userId));
107
107
  const mountedRef = useRef(true);
108
+ // Tracks "we have already resolved a no-activeId conversation for this user".
109
+ // Prevents creating duplicate conversations when sibling state (e.g. the
110
+ // page's selected agent / `scope`) transitions during the same /ai visit.
111
+ const resolvedForUserRef = useRef(undefined);
108
112
  useEffect(() => {
109
113
  mountedRef.current = true;
110
114
  return () => {
@@ -116,6 +120,12 @@ export function useChatConversation(options) {
116
120
  setConversationId(undefined);
117
121
  setInitialMessages([]);
118
122
  setIsLoading(false);
123
+ resolvedForUserRef.current = undefined;
124
+ return;
125
+ }
126
+ // Already resolved a conversation for this user during the no-activeId
127
+ // window — don't re-create just because `scope` or another dep changed.
128
+ if (!activeId && resolvedForUserRef.current === userId && conversationId) {
119
129
  return;
120
130
  }
121
131
  let cancelled = false;
@@ -123,24 +133,42 @@ export function useChatConversation(options) {
123
133
  setIsLoading(true);
124
134
  (async () => {
125
135
  try {
126
- const cached = readCache(key);
127
- if (cached) {
128
- const existing = await fetchConversation(apiBase, cached);
136
+ if (activeId) {
137
+ const existing = await fetchConversation(apiBase, activeId);
129
138
  if (cancelled)
130
139
  return;
131
140
  if (existing) {
141
+ writeCache(key, existing.id);
132
142
  setConversationId(existing.id);
133
143
  setInitialMessages(toUIMessages(existing.messages));
144
+ resolvedForUserRef.current = userId;
134
145
  return;
135
146
  }
147
+ // Requested id is gone — fall through to create a fresh one.
136
148
  writeCache(key, undefined);
137
149
  }
150
+ else {
151
+ const cached = readCache(key);
152
+ if (cached) {
153
+ const existing = await fetchConversation(apiBase, cached);
154
+ if (cancelled)
155
+ return;
156
+ if (existing) {
157
+ setConversationId(existing.id);
158
+ setInitialMessages(toUIMessages(existing.messages));
159
+ resolvedForUserRef.current = userId;
160
+ return;
161
+ }
162
+ writeCache(key, undefined);
163
+ }
164
+ }
138
165
  const fresh = await createConversation(apiBase);
139
166
  if (cancelled)
140
167
  return;
141
168
  writeCache(key, fresh.id);
142
169
  setConversationId(fresh.id);
143
170
  setInitialMessages(toUIMessages(fresh.messages));
171
+ resolvedForUserRef.current = userId;
144
172
  }
145
173
  catch {
146
174
  if (!cancelled) {
@@ -156,7 +184,11 @@ export function useChatConversation(options) {
156
184
  return () => {
157
185
  cancelled = true;
158
186
  };
159
- }, [userId, scope, apiBase]);
187
+ // `conversationId` intentionally omitted: it's only read inside the
188
+ // short-circuit guard, which is governed by the ref. Including it would
189
+ // re-run the effect after we successfully resolved an id.
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [userId, scope, apiBase, activeId]);
160
192
  const reset = useCallback(async () => {
161
193
  if (!userId)
162
194
  return;
@@ -0,0 +1,25 @@
1
+ export interface ConversationSummary {
2
+ id: string;
3
+ title?: string;
4
+ agentId?: string;
5
+ createdAt?: string;
6
+ updatedAt?: string;
7
+ /** Preview text derived from the most recent message, if available. */
8
+ preview?: string;
9
+ }
10
+ export interface UseConversationListOptions {
11
+ userId: string | undefined;
12
+ apiBase: string;
13
+ limit?: number;
14
+ /** Bump to force a refetch (e.g. after creating/deleting a conversation). */
15
+ refreshKey?: number | string;
16
+ }
17
+ export interface UseConversationListReturn {
18
+ conversations: ConversationSummary[];
19
+ isLoading: boolean;
20
+ error: Error | undefined;
21
+ refetch: () => Promise<void>;
22
+ remove: (id: string) => Promise<void>;
23
+ rename: (id: string, title: string) => Promise<void>;
24
+ }
25
+ export declare function useConversationList(options: UseConversationListOptions): UseConversationListReturn;