From 4661c94483a2e2116f429e251e8f724603c3a3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 09:49:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(app-shell):=20=E2=8C=98K=20recent=20ro?= =?UTF-8?q?ws=20show=20their=20object's=20type=20and=20icon=20(objectui#12?= =?UTF-8?q?852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row of the command palette's recent group drew the same search glyph and its title alone, so two recent records that share a title (an organisation and its environment) rendered identically. A row now shows its object's label on the right and its object's icon on the left, as Home's "Recently accessed" row does. The object is read off the stored address by the existing recentItemObject; an address that names no object gives a row with no type and the neutral record glyph. Claude-Session: https://claude.ai/code/session_01SRHG3vksxM8h9PHq9mPEUW Co-authored-by: Claude --- .../12852-command-palette-recent-item-type.md | 5 + ...mmandPalette.recentItemType-12852.test.tsx | 180 ++++++++++++++++++ .../app-shell/src/chrome/CommandPalette.tsx | 61 ++++-- 3 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 .changeset/12852-command-palette-recent-item-type.md create mode 100644 packages/app-shell/src/chrome/CommandPalette.recentItemType-12852.test.tsx diff --git a/.changeset/12852-command-palette-recent-item-type.md b/.changeset/12852-command-palette-recent-item-type.md new file mode 100644 index 0000000000..7e33aa8930 --- /dev/null +++ b/.changeset/12852-command-palette-recent-item-type.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The command palette's recently viewed rows now show what each item is: the record's object label on the right (in the viewer's language) and the object's icon on the left, in place of the same search icon on every row. Two recent records with the same name but different objects — an organisation and its environment, say — no longer look identical. A record whose stored address names no object shows no type and a neutral record icon. diff --git a/packages/app-shell/src/chrome/CommandPalette.recentItemType-12852.test.tsx b/packages/app-shell/src/chrome/CommandPalette.recentItemType-12852.test.tsx new file mode 100644 index 0000000000..3cb3c49f75 --- /dev/null +++ b/packages/app-shell/src/chrome/CommandPalette.recentItemType-12852.test.tsx @@ -0,0 +1,180 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Each row of the ⌘K palette's recent group shows what it is (objectui#12852). + * + * Every row drew the same search glyph and its title alone, so two entries + * that share a title — an organisation and its environment, both + * "Objectstack" — were drawn identically. A row now carries its object's label + * on the right and its object's icon on the left, as Home's "Recently + * accessed" row does (objectui#12604). The object is the one `recentItemObject` + * reads off the stored address, `/apps/APP/OBJECT/record/ID`. + * + * Pinned through the real `CommandPalette` + `CommandPaletteProvider` (opened + * by its `?palette=1` deep link) under a real `MetadataCtx` and `I18nProvider`. + * The recent-items store is replaced with a fixed list, and `getIcon` with a + * stub that draws the icon NAME it was asked for, so the assertion reads which + * icon a row chose rather than how Lucide draws it lazily. + * + * - two records with the same title and different objects show different + * type text and different icons — the card's acceptance; + * - the type is the object's label in the viewer's language (zh-CN, en); + * - CONTROL: an entry whose object cannot be read off its address shows no + * type and the neutral record glyph; + * - CONTROL: the group still lists the first five record entries, in order, + * under its heading, and choosing a row opens its address. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import type { RecentItem } from '../context/RecentItemsProvider'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }), +})); + +const recent = vi.hoisted(() => ({ items: [] as unknown[] })); +vi.mock('../context/RecentItemsProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecentItems: () => ({ recentItems: recent.items, addRecentItem: () => {}, clearRecentItems: () => {} }), +})); + +vi.mock('../utils/getIcon', () => ({ + getIcon: (name: string) => + function StubIcon(props: { className?: string }) { + return ; + }, +})); + +import { CommandPalette } from './CommandPalette'; +import { CommandPaletteProvider } from '../context/CommandPaletteProvider'; + +const APP = { name: 'cloud', label: 'Cloud', navigation: [] }; + +const OBJECTS = [ + { name: 'sys_organization', label: { en: 'Organization', 'zh-CN': '组织' }, icon: 'Building2' }, + { name: 'sys_environment', label: { en: 'Environment', 'zh-CN': '环境' }, icon: 'Server' }, +]; + +const METADATA = { + apps: [APP], + objects: OBJECTS, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +const VISITED = '2026-10-17T00:00:00.000Z'; +const record = (id: string, href: string, label: string): RecentItem => ({ id, type: 'record', href, label, visitedAt: VISITED }); + +const ORG = record('record:sys_organization:o1', '/apps/cloud/sys_organization/record/o1', 'Objectstack'); +const ENV = record('record:sys_environment:e1', '/apps/cloud/sys_environment/record/e1', 'Objectstack'); + +function Where() { + const location = useLocation(); + return ; +} + +function mount(items: RecentItem[], language = 'en') { + recent.items = items; + render( + + + + + + {}} /> + + + + , + ); + if (!document.querySelector('[cmdk-input]')) throw new Error('the palette did not open'); +} + +/** Every row of the recent group: its title, its type text, and the icon it drew. */ +function recentRows() { + return Array.from(document.querySelectorAll('[cmdk-item][data-value^="recent "]')).map((el) => { + const svg = el.querySelector('svg'); + return { + title: el.querySelector('span.truncate:not([data-testid])')?.textContent ?? '', + type: el.querySelector('[data-testid="palette-recent-type"]')?.textContent ?? null, + icon: svg?.getAttribute('data-glyph') ?? (svg?.classList.contains('lucide-file-text') ? 'FileText' : svg?.getAttribute('class')), + }; + }); +} + +afterEach(() => { + cleanup(); + recent.items = []; +}); + +describe('objectui#12852 — a ⌘K recent row shows its type and its object’s icon', () => { + it('two records with the same title and different objects are never drawn alike', () => { + mount([ORG, ENV]); + const rows = recentRows(); + expect(rows).toEqual([ + { title: 'Objectstack', type: 'Organization', icon: 'Building2' }, + { title: 'Objectstack', type: 'Environment', icon: 'Server' }, + ]); + expect(rows[0].type).not.toBe(rows[1].type); + expect(rows[0].icon).not.toBe(rows[1].icon); + }); + + it('under zh-CN the type is the object’s zh-CN label', () => { + mount([ORG, ENV], 'zh-CN'); + expect(recentRows().map((r) => r.type)).toEqual(['组织', '环境']); + }); + + it('control: an entry whose object cannot be read off its address shows no type and the neutral glyph', () => { + // Not `/apps/APP/OBJECT/record/ID`: the address names no object. + mount([record('record:x', '/apps/cloud/record/x', 'Objectstack'), record('record:y', '/settings/profile', 'Profile')]); + expect(recentRows()).toEqual([ + { title: 'Objectstack', type: null, icon: 'FileText' }, + { title: 'Profile', type: null, icon: 'FileText' }, + ]); + }); + + it('control: an object the metadata does not hold is typed by its machine name, with the neutral glyph', () => { + mount([record('record:crm_lead:l1', '/apps/crm/crm_lead/record/l1', 'Objectstack')]); + expect(recentRows()).toEqual([{ title: 'Objectstack', type: 'crm_lead', icon: 'FileText' }]); + }); + + it('control: the group lists the first five record entries, in order, under its heading, and a row opens its address', () => { + const records = Array.from({ length: 7 }, (_, i) => + record(`record:sys_organization:o${i}`, `/apps/cloud/sys_organization/record/o${i}`, `Org ${i}`), + ); + mount([ + { id: 'object:sys_organization', type: 'object', name: 'sys_organization', href: '/apps/cloud/sys_organization', visitedAt: VISITED }, + records[0], + { id: 'page:home', type: 'page', name: 'home', href: '/apps/cloud/page/home', visitedAt: VISITED }, + ...records.slice(1), + ]); + const group = document.querySelector('[cmdk-group-heading]'); + expect(group?.textContent).toBe('Recently viewed'); + expect(recentRows().map((r) => r.title)).toEqual(['Org 0', 'Org 1', 'Org 2', 'Org 3', 'Org 4']); + + fireEvent.click(document.querySelectorAll('[cmdk-item][data-value^="recent "]')[2]); + expect(screen.getByTestId('location').getAttribute('data-path')).toBe('/apps/cloud/sys_organization/record/o2'); + expect(document.querySelector('[data-testid="overlay:command-palette"]')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 84afbfc345..0126161383 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -49,11 +49,13 @@ import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '.. import { getIcon } from '../utils/getIcon.js'; import { useRecentItems } from '../context/RecentItemsProvider.js'; import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js'; +import { recentItemObject } from '../console/home/recentItemTypeLabel.js'; +import type { RecentItem } from '../context/RecentItemsProvider.js'; import { useCommandPalette } from '../context/CommandPaletteProvider.js'; import { useMetadata } from '../providers/MetadataProvider.js'; import { resolveHref, resolveNavItemLabel } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; -import { useNavTargetLabel } from '../hooks/useNavTargetLabel.js'; +import { findByName, useNavTargetLabel } from '../hooks/useNavTargetLabel.js'; import { useAuth } from '@object-ui/auth'; import { matchesPaletteQuery } from './paletteMatch.js'; import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io.js'; @@ -225,6 +227,31 @@ export function CommandPalette(props: CommandPaletteProps) { ); const showRecentRecords = open && inputValue.trim().length === 0 && recentRecords.length > 0; + /** + * What a recent row draws beside its title (objectui#12852), so two entries + * that share a title — an organisation and its environment — are never drawn + * alike: its object's label on the right and its object's icon on the left, + * as Home's "Recently accessed" row draws them (objectui#12604). + * + * The object is the one `recentItemObject` reads off the address the record + * was stored under, `/apps/APP/OBJECT/record/ID`; nothing else on the entry + * names it. Its label is the object's current one in the viewer's language + * (`useNavTargetLabel`), else its machine name; its icon is the object's + * declared one, else the record kind's glyph. An entry stored under any other + * address names no object, so its row shows no type and keeps the neutral + * glyph — ⛔ never a type guessed from the title. + */ + const objectDefs = useMetadata().objects; + const recentContext = (item: RecentItem): { type?: string; icon?: string } => { + const objectName = recentItemObject(item); + if (!objectName) return {}; + const def = findByName(objectDefs, objectName) as { icon?: unknown } | undefined; + return { + type: targetLabel({ kind: 'object', objectName }) ?? objectName, + icon: typeof def?.icon === 'string' && def.icon !== '' ? def.icon : undefined, + }; + }; + // Index object defs by name so group headings can resolve the object's // localized label (labels may be `{ key, defaultValue }` i18n objects, which // the hook's plain-string `objectLabel` can't carry). @@ -306,16 +333,28 @@ export function CommandPalette(props: CommandPaletteProps) { - {recentRecords.map((item) => ( - runCommand(() => navigate(item.href))} - > - - {recentLabel(item)} - - ))} + {recentRecords.map((item) => { + const { type, icon } = recentContext(item); + const Icon = icon ? getIcon(icon) : FileText; + return ( + runCommand(() => navigate(item.href))} + > + + {recentLabel(item)} + {type && ( + + {type} + + )} + + ); + })} )} From 9777dcba169e9d2612c78fd0dece313a69c10402 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 09:54:08 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(app-shell):=20a=20closed=20=E2=8C=98K?= =?UTF-8?q?=20palette=20reads=20no=20object=20metadata=20(objectui#12852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recent rows' object lookup read `useMetadata().objects` at render, which starts the object list's load while the palette is still closed (CommandPalette.studioScope-11863 "reads nothing until the palette opens"). Read it inside the row function, which runs only for drawn rows. Claude-Session: https://claude.ai/code/session_01SRHG3vksxM8h9PHq9mPEUW Co-authored-by: Claude --- packages/app-shell/src/chrome/CommandPalette.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 0126161383..54a66fc26d 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -240,12 +240,16 @@ export function CommandPalette(props: CommandPaletteProps) { * declared one, else the record kind's glyph. An entry stored under any other * address names no object, so its row shows no type and keeps the neutral * glyph — ⛔ never a type guessed from the title. + * + * `objects` is read inside the function, never at render: the metadata + * cache loads the object list when it is first read, and a closed palette + * reads nothing (objectui#11863). */ - const objectDefs = useMetadata().objects; + const metadata = useMetadata(); const recentContext = (item: RecentItem): { type?: string; icon?: string } => { const objectName = recentItemObject(item); if (!objectName) return {}; - const def = findByName(objectDefs, objectName) as { icon?: unknown } | undefined; + const def = findByName(metadata.objects, objectName) as { icon?: unknown } | undefined; return { type: targetLabel({ kind: 'object', objectName }) ?? objectName, icon: typeof def?.icon === 'string' && def.icon !== '' ? def.icon : undefined,