Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/11678-recent-items-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': minor
---

"Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678).

The route tracker used to store a label it made from the route's machine name at visit time: a dashboard labelled "Delivery Operations" was listed as "Showcase Ops Dashboard", and stayed in English after switching to 中文. An object, dashboard, page or report entry now stores its identity only, `type` and `name`, and every surface that lists recent items (the Home cards and rail, the sidebar's Recent group, Studio home, the command palette) labels it when it renders, through the new `useRecentItemLabel` hook. That hook reuses the resolver each kind already has: the navigation target label for an object or a dashboard, and the page's or report's metadata label through the `pageLabel` / `reportLabel` bundle lookup. An item whose metadata is not loaded shows its machine name until it is. A record keeps the title it was visited under, and a Studio metadata item its name.

Lists already stored in the old shape are read by identity, which was always in the entry's `id`; their stored label is dropped, so they show the current label on first render.

`RecentItemsProvider` now refuses to write a list equal to the one it holds, whoever reports the visit: revisiting the item already at the head of the list changes nothing and writes nothing, and its `visitedAt` stays the time it became the most recent entry. Going from an object's list to one of its views no longer saves the list a second time.

**Breaking (types):** `RecentItem` is now a union. `RecentNamedItem` (`object`, `dashboard`, `page`, `report`) carries `name` and no `label`; `RecentTextItem` (`record`, `metadata`) carries `label`. `addRecentItem` takes `RecentItemInput`, so a call that passes a `label` for an object, dashboard, page or report no longer compiles, and code that reads `item.label` off a recent entry should call `useRecentItemLabel()` instead. `useRecentItemLabel`, `RecentItemLabelResolver`, `RecentItemInput`, `RecentItemType`, `RecentNamedItem` and `RecentTextItem` are new exports of `@object-ui/app-shell`.

**Clause-②: yes (narrowing).** The package entry gains six exports, and `addRecentItem` refuses the label-carrying shape it used to take for those four kinds.
7 changes: 7 additions & 0 deletions content/docs/guide/user-state-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ The providers debounce backend writes with a 500ms window via `createDebouncedFl

`localStorage` is written **synchronously** on every mutation, so reloads always show the latest state immediately even if the debounced backend write hasn't fired yet.

## Recent items store identity, not labels

An object, dashboard, page or report entry in the recent list is stored as its `type` and `name` (plus `href` and `visitedAt`), never as display text, so a rename or a language switch shows on the next render instead of being frozen at visit time. Surfaces label an entry with `useRecentItemLabel()`, which asks the resolver the console already uses for that kind; an item whose metadata is not loaded yet shows its machine name. Record entries (`type: 'record'`) and Studio metadata entries (`type: 'metadata'`) carry a `label`: a record's title, a metadata item's name.

The provider writes nothing when a visit leaves the list unchanged: re-adding the entry already at the head keeps its `visitedAt` and costs no `save()`. Lists persisted in the earlier label-carrying shape are read by identity (the name is the part of `id` after `type:`) and their stored label is dropped.

## Race-safety

Each provider keeps a monotonic `hydrationToken`. If the user switches accounts while a `load()` is in flight, the late response is discarded. This prevents seeing User A's favorites flash into User B's session.
Expand All @@ -199,6 +205,7 @@ Each provider keeps a monotonic `hydrationToken`. If the user switches accounts
| `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds }` |
| `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins }`. |
| `useRecentItems()` | `@object-ui/app-shell` | `{ recentItems, addRecentItem, clearRecentItems }` |
| `useRecentItemLabel()` | `@object-ui/app-shell` | `(item) => string` — a recent entry's label, resolved on render from the item's metadata in the current language. |
| `useFlowPaletteRecents()` | `@object-ui/app-shell` | `{ recents, recordRecent }` — flow-designer add-node MRU; falls back to localStorage outside a provider. |
| `createObjectStackUserStateAdapter(opts)` | `@object-ui/data-objectstack` | Official adapter over the platform's `sys_user_preference` store. `{ dataSource, userId, key, resource?, onError? }` — one instance per `(user, key)` pair. |

Expand Down
17 changes: 17 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,23 @@ const { recentItems, addRecentItem } = useRecentItems();
const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();
```

A recent object, dashboard, page or report entry stores its identity only
(`type` and `name`), never display text: label it where you render it with
`useRecentItemLabel()`, which reads the item's current metadata label in the
current language (a record entry keeps the title it was visited under). The
provider writes nothing when a visit leaves the list unchanged, such as
revisiting the item already at its head.

```tsx
import { useRecentItems, useRecentItemLabel } from '@object-ui/app-shell';

function RecentList() {
const { recentItems } = useRecentItems();
const recentLabel = useRecentItemLabel();
return <ul>{recentItems.map((item) => <li key={item.id}>{recentLabel(item)}</li>)}</ul>;
}
```

Nav pins and Favorites share a single `favorites` collection. `FavoriteItem`
carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single
adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
Expand Down
8 changes: 6 additions & 2 deletions packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils/index.js';
import { getIcon } from '../utils/getIcon.js';
import { useRecentItems } from '../context/RecentItemsProvider.js';
import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js';
import { useCommandPalette } from '../context/CommandPaletteProvider.js';
import { resolveHref, resolveNavItemLabel } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
Expand Down Expand Up @@ -125,6 +126,9 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
// user types anything. Filtered down to record-type entries so we
// don't double up with the per-app nav above.
const { recentItems } = useRecentItems();
// The one way a recent entry is labelled (objectui#11678); for a record it
// is the title the entry was visited under.
const recentLabel = useRecentItemLabel();
const recentRecords = useMemo(
() => recentItems.filter((it) => it.type === 'record').slice(0, 5),
[recentItems],
Expand Down Expand Up @@ -215,11 +219,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
{recentRecords.map((item) => (
<CommandItem
key={`recent:${item.id}`}
value={`recent ${item.label} ${item.id}`}
value={`recent ${recentLabel(item)} ${item.id}`}
onSelect={() => runCommand(() => navigate(item.href))}
>
<Search className="mr-2 h-4 w-4" />
<span className="truncate">{item.label}</span>
<span className="truncate">{recentLabel(item)}</span>
</CommandItem>
))}
</CommandGroup>
Expand Down
6 changes: 5 additions & 1 deletion packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { useRecentItems } from '../../hooks/useRecentItems.js';
import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js';
import { useFavorites } from '../../hooks/useFavorites.js';
import { useObjectTranslation } from '@object-ui/i18n';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
Expand Down Expand Up @@ -237,6 +238,9 @@ export function HomePage() {
const { t } = useObjectTranslation();
const { apps, loading } = useMetadata();
const { recentItems } = useRecentItems();
// A recent entry stores identity; the rail labels it with this, on the
// render, in the current language (objectui#11678).
const recentLabel = useRecentItemLabel();
const { favorites } = useFavorites();
const { user } = useAuth();
const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus();
Expand Down Expand Up @@ -593,7 +597,7 @@ export function HomePage() {

{/* Continue where you left off + ambient activity */}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<HomeContinue items={recentApps} onOpen={(href) => navigate(href)} t={t} />
<HomeContinue items={recentApps} onOpen={(href) => navigate(href)} t={t} labelOf={recentLabel} />
<HomeActivity items={activities} onViewAll={() => navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
</div>
</div>
Expand Down
22 changes: 20 additions & 2 deletions packages/app-shell/src/console/home/HomeRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useDisplayLocale } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed.js';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import { timeAgo } from '../../utils/relativeTime.js';

Expand Down Expand Up @@ -236,7 +237,24 @@ const RECENT_TONE: Record<string, string> = {
page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};

export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen: (href: string) => void; t: TFn }) {
/**
* `labelOf` is `useRecentItemLabel()`, handed in by the page like `t`: an entry
* stores identity, not text, so its label is resolved on the render, in the
* current language (objectui#11678). Taken as a prop so this module — which
* also holds the action center and the activity card — does not itself load
* the metadata cache the resolver reads.
*/
export function HomeContinue({
items,
onOpen,
t,
labelOf,
}: {
items: RecentItem[];
onOpen: (href: string) => void;
t: TFn;
labelOf: RecentItemLabelResolver;
}) {
return (
<Card icon={Clock} title={t('home.recentApps.title', { defaultValue: 'Recently Accessed' })}>
{items.length === 0 ? (
Expand All @@ -250,7 +268,7 @@ export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen
key={it.id}
icon={RECENT_ICON[it.type] || FileText}
iconClass={RECENT_TONE[it.type] || 'bg-muted text-muted-foreground'}
label={it.label}
label={labelOf(it)}
meta={recentItemTypeLabel(t, it.type)}
onClick={() => onOpen(it.href)}
/>
Expand Down
6 changes: 5 additions & 1 deletion packages/app-shell/src/console/home/RecentApps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Card, CardContent, cn } from '@object-ui/components';
import { Clock, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js';

interface RecentAppsProps {
items: RecentItem[];
Expand All @@ -36,6 +37,9 @@ const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
export function RecentApps({ items }: RecentAppsProps) {
const navigate = useNavigate();
const { t } = useObjectTranslation();
// The entry stores identity, not text: its label is the item's own, read in
// the current language on this render (objectui#11678).
const recentLabel = useRecentItemLabel();

if (items.length === 0) return null;

Expand Down Expand Up @@ -75,7 +79,7 @@ export function RecentApps({ items }: RecentAppsProps) {
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{item.label}</h3>
<h3 className="font-medium text-sm truncate">{recentLabel(item)}</h3>
<p className="text-xs text-muted-foreground">{typeLabel}</p>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-[opacity,transform] duration-200 group-hover:opacity-100 group-hover:translate-x-0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => {

import { useObjectTranslation } from '@object-ui/i18n';
import { HomeContinue } from '../HomeRail.js';
import { useRecentItemLabel } from '../../../hooks/useRecentItemLabel.js';
import { RecentApps } from '../RecentApps.js';
import { StarredApps } from '../StarredApps.js';

Expand All @@ -79,7 +80,9 @@ const ITEM_LABEL = 'Quarterly revenue';
*/
function Rail({ items }: { items: RecentItem[] }) {
const { t } = useObjectTranslation();
return <HomeContinue items={items} onOpen={() => {}} t={t} />;
// HomePage hands the rail its labeller the same way (objectui#11678).
const labelOf = useRecentItemLabel();
return <HomeContinue items={items} onOpen={() => {}} t={t} labelOf={labelOf} />;
}

/**
Expand All @@ -98,15 +101,21 @@ function typeLabelOf(node: HTMLElement, itemLabel: string): string {
}

function renderAllThree(kind: string) {
const recent: RecentItem[] = [
// A synthetic kind is no member of the `RecentItem` union, so the entry is
// cast. It carries BOTH `name` (what a named kind — `report` here — is
// labelled from when no metadata is loaded) and `label` (what any other kind
// shows), so every surface renders ITEM_LABEL whichever arm the kind falls in
// (objectui#11678).
const recent = [
{
id: 'r1',
name: ITEM_LABEL,
label: ITEM_LABEL,
href: '/x',
type: kind as RecentItem['type'],
type: kind,
visitedAt: new Date().toISOString(),
},
];
] as unknown as RecentItem[];
const favorites: FavoriteItem[] = [
{
id: 'f1',
Expand Down
Loading
Loading