diff --git a/.agents/docs/components-package.md b/.agents/docs/components-package.md index f09dc60a59..80bb956b0e 100644 --- a/.agents/docs/components-package.md +++ b/.agents/docs/components-package.md @@ -232,6 +232,15 @@ button for a file the machine will refuse, and the user would lose the edit at s ## Theming and fields +Empty settings catalogs use `SettingsEmptyList`: a centered decorative jellyfish, +a localized message, and any existing guidance directly on the page. A records +card appears once there are records. Existing catalog copy is preserved verbatim, +including punctuation and instructions; no new guidance is added. +The page owns its add action, while the optional empty-state action supports a +different entry point such as GitHub installation. Project Skills uses this +presentation for an empty inventory; its errors and search misses keep their +existing messages. See the [empty catalog decision](../notes/implemented/feature/2026-10-20-settings-empty-catalogs.md). + Reusable visual primitives and semantic design tokens are owned by [`@lody/ui`](../../packages/ui/README.md). `@lody/components` composes those primitives into product surfaces and may add layout or interaction constraints, diff --git a/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.md b/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.md new file mode 100644 index 0000000000..59a0c74b4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.md @@ -0,0 +1,71 @@ +# Illustrated empty settings catalogs + +Status: implemented +Translation: current +PR: [#1745](https://github.com/LodyAI/Lody/pull/1745) + +[中文](2026-10-20-settings-empty-catalogs.zh.md) + +## Abstract + +Empty settings catalogs looked like a record card containing only an absence +message. They now present a centered Lody jellyfish illustration above localized +copy on the page, preserving every existing message and instruction verbatim. +The shared component also covers existing project, GitHub, share, and provider +empty states, and the empty Project Skills inventory. Existing add actions remain +available, and the illustration is decorative. Data ownership, loading conditions, +permissions, error states, and populated lists retain their existing behavior. + +## Decision + +This replaces the empty-catalog presentation from the +[settings rhythm decision](2026-09-25-settings-rhythm-and-image-peek.md#design-review-round-2). +`SettingsEmptyList` now draws on the page rather than using `settingsRecordsCard`. +It shares StyleX geometry through the settings surface and accepts an optional +description. It has no internal scroller; copy wraps inside narrow panels. The +page keeps its existing add action, while the existing optional action slot still +supports GitHub installation without adding a second page action. + +Existing English and Chinese copy is preserved verbatim, including punctuation +and instructions. No new guidance is added. The optional description carries the +existing Project Skills hint. The same presentation serves +both saved memory associations and an empty external memory inventory. Project +Skills uses it only for an empty inventory, keeping error/retry and search-result +messages separate. No catalog writes, provider requests, selection rules or +capability gates changed. + +## Illustration + +The new [transparent PNG](../../../../packages/components/src/assets/settings-empty-jellyfish.png) +was generated with the built-in `image_gen` tool using the existing `lody-icon.png` +as the brand reference. A blue/teal jellyfish searches an empty folder with a +magnifying glass; there is no embedded text or theme-colored backdrop. The +[generation prompt](../../../../packages/components/src/assets/settings-empty-jellyfish.prompt.txt) +is retained with the asset. The image is local to the public package and rendered +with empty alt text and `aria-hidden`; the adjacent copy carries the meaning. + +## Verification + +The components type check, typed lint, translation-key validation and 17 existing +memory, MCP and Prompt Shortcut form tests pass. Two desktop browser cases cover +six empty catalogs in English/light/1180px and Chinese/dark/500px, including image +loading, centering, wrapping, no card or internal scroller, add dialogs, and +loading/read-only conditions. Two mobile cases cover Roles and MCP in both +palettes at 393×852 with touch input. All four browser cases pass; the local run +uses Chromium build 1223 and allows 30 seconds for Storybook rendering. + +`pnpm format` passes. The latest `pnpm check` completed type checks and lint but +failed on a five-second timeout in the unchanged iOS simulator panel suite +(5,742 component tests passed). That entire 25-test suite passes independently; +all 4,423 CLI tests passed, with four skipped. Translation and boundary checks +were also run separately and pass. The complete root check is therefore not +green. Documentation checking retains the six existing missing Kimi/Pi submodule +links, with no new errors or protected-topic changes. + +## Screenshots + +Before and after screenshots are attached to [PR #1745](https://github.com/LodyAI/Lody/pull/1745) +instead of being tracked in the repository. They use the actual catalog components +with synthetic fixtures. Desktop uses an 1180×800 viewport and shows the content +pane; mobile uses the production `MobileSettingsLayout` in a 393×852 touch viewport. +Both palettes are included, with matching fixtures and viewports for each pair. diff --git a/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.zh.md b/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.zh.md new file mode 100644 index 0000000000..4bcf144f24 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-settings-empty-catalogs.zh.md @@ -0,0 +1,60 @@ +# 带插画的设置目录空状态 + +Status: implemented +Translation: current +PR: [#1745](https://github.com/LodyAI/Lody/pull/1745) + +[English](2026-10-20-settings-empty-catalogs.md) + +## 摘要 + +设置目录为空时,原先只显示一张写着“没有”的记录卡片。 +现在直接在页面上居中展示 Lody 水母插画与本地化文字,原有提示和操作说明保持原文。 +共用组件也覆盖已有项目、GitHub、分享、Provider +的空状态,以及空的项目 Skills 目录。已有添加入口继续可用,插画仅作装饰。 +数据归属、加载条件、权限、错误状态和非空列表均保持原有行为。 + +## 决定 + +本次替换[设置节奏决定](2026-09-25-settings-rhythm-and-image-peek.md#design-review-round-2) +中的空目录展示方式。`SettingsEmptyList` 直接绘制在页面上,不再使用 +`settingsRecordsCard`。共用 StyleX 布局由设置 surface 管理,并新增可选说明文字。 +组件内部没有滚动区域,文字能在窄面板内换行。页面保留已有添加按钮;原有的可选 +操作区域仍能承载 GitHub 安装入口,不会额外重复页面的添加动作。 + +原有中英文文案逐字保留,包括标点和操作说明,不新增引导文案。 +可选描述承载项目 Skills 已有的提示。 +已保存的记忆关联与外部记忆目录为空时共用同样的展示方式。项目 Skills 只在目录 +确实为空时使用插画,错误重试与搜索未命中的提示继续单独显示。 +没有改变目录写入、Provider 请求、选择规则或能力开关。 + +## 插画 + +新的[透明 PNG](../../../../packages/components/src/assets/settings-empty-jellyfish.png) +由内置 `image_gen` 工具生成,以现有 `lody-icon.png` 作为品牌参考。蓝绿色水母拿着 +放大镜寻找空文件夹,没有内嵌文字或限定主题的背景。 +[生成提示词](../../../../packages/components/src/assets/settings-empty-jellyfish.prompt.txt) +与素材一同保存。图片作为公共包的本地资源,使用空 alt 和 `aria-hidden` 标记为装饰, +相邻文字负责传达信息。 + +## 验证 + +组件类型检查、类型感知 lint、翻译键验证,以及已有记忆、MCP、Prompt Shortcut +表单测试共 17 项通过。两个桌面浏览器案例分别使用英文/亮色/1180px 与 +中文/暗色/500px,覆盖六种空目录的图片加载、居中、换行、没有空卡片或内部滚动区、 +已有添加弹窗,以及加载和只读状态。两个手机案例在 393×852 触屏视口下检查角色与 +MCP 的亮暗主题。四个浏览器案例均通过;本地使用 Chromium build 1223,并允许 +Storybook 在 30 秒内完成渲染。 + +`pnpm format` 通过。最新完整 `pnpm check` 的类型检查与 lint 通过,但未修改的 +iOS 模拟器面板测试有一项在五秒后超时,组件测试其余 5,742 项通过。 +所属 25 项测试单独运行全部通过;CLI 测试 4,423 项通过、四项跳过。 +翻译和边界检查另行运行均通过,因此不能将此次全仓检查记为全绿。 +文档检查保留原有六个 Kimi/Pi 子模块缺失链接,没有新增错误或保护主题变化。 + +## 截图 + +修改前后的截图以附件形式放在 [PR #1745](https://github.com/LodyAI/Lody/pull/1745),不纳入仓库。 +使用真实目录组件与合成示例数据采集。桌面视口为 1180×800,截图聚焦内容区域; +手机使用正式的 `MobileSettingsLayout`,触屏视口为 393×852。两端均包含亮暗主题, +每组前后对照使用相同的示例数据和视口。 diff --git a/packages/components/src/assets/settings-empty-jellyfish.png b/packages/components/src/assets/settings-empty-jellyfish.png new file mode 100644 index 0000000000..1759996da4 Binary files /dev/null and b/packages/components/src/assets/settings-empty-jellyfish.png differ diff --git a/packages/components/src/assets/settings-empty-jellyfish.prompt.txt b/packages/components/src/assets/settings-empty-jellyfish.prompt.txt new file mode 100644 index 0000000000..87440aa53c --- /dev/null +++ b/packages/components/src/assets/settings-empty-jellyfish.prompt.txt @@ -0,0 +1,5 @@ +Generator: built-in image_gen +Brand reference: lody-icon.png in this directory +Output: settings-empty-jellyfish.png, transparent background + +Use case: illustration-story. Asset type: a small, polished empty-state illustration for Lody's software settings pages. The supplied image is a BRAND REFERENCE, not an image to reproduce unchanged. Generate a new original scene based on this existing jellyfish mascot: preserve its blue-to-teal dome, organic silhouette, and flowing curved tentacles. Show a single small jellyfish tilting curiously, using one tentacle to hold a simple magnifying glass as it searches an empty open folder. Gentle, quietly playful 'nothing here yet' feeling, not sad or alarmed. Refined minimal editorial illustration, softly shaded blue/teal gradients and subtle grain matching the reference. Keep the recognizable jellyfish form; do not turn it into a human or add arms/legs. Very sparse composition, centered, generous transparent margins, clear at 160 pixels, no surrounding scene, no card, no decorative frame, no floor, no background glow. True transparent background so it works on both white and dark gray UI. No words, letters, symbols, logos, watermark, UI, or text anywhere. diff --git a/packages/components/src/components/settings/compact-layout.tsx b/packages/components/src/components/settings/compact-layout.tsx index 3f569259c4..335f45cb20 100644 --- a/packages/components/src/components/settings/compact-layout.tsx +++ b/packages/components/src/components/settings/compact-layout.tsx @@ -2,6 +2,7 @@ import React, { forwardRef, type ComponentProps, type ReactNode } from 'react'; import * as stylex from '@stylexjs/stylex'; import { ArrowUpRight, ChevronRight } from 'lucide-react'; import { withClassName } from '@/lib/stylex'; +import emptyJellyfish from '@/assets/settings-empty-jellyfish.png'; import { settingsBoxed } from './material.stylex'; import { settingsSurface as surface } from './surface'; @@ -210,28 +211,34 @@ export const CompactLinkRow = forwardRef ); /** - * An empty catalog, in its list's own geometry: the records card it will - * hold, with one quiet line where the records will be. No centred icon and no - * second copy of the page's add action; `action` is for the one list whose way - * in is not the page's add (installing the GitHub App). + * An empty catalog stands on the page rather than drawing an empty records + * card. The decorative mascot and centered copy explain what belongs here; + * the page keeps its add action. `action` supports a different way in, such as + * installing the GitHub App. */ export function SettingsEmptyList({ children, + description, action, }: { children: ReactNode; + description?: ReactNode; action?: ReactNode; }) { return ( -
- {action ? ( -
- {children} - {action} -
- ) : ( -

{children}

- )} +
+ +

{children}

+ {description ?

{description}

: null} + {action ?
{action}
: null}
); } diff --git a/packages/components/src/components/settings/project-skills-tab.tsx b/packages/components/src/components/settings/project-skills-tab.tsx index 772c2842ab..c56b6b905b 100644 --- a/packages/components/src/components/settings/project-skills-tab.tsx +++ b/packages/components/src/components/settings/project-skills-tab.tsx @@ -5,7 +5,7 @@ import { usePostHog } from '@posthog/react'; import { formatDistanceToNow, type Locale } from 'date-fns'; import { enUS } from 'date-fns/locale/en-US'; import { zhCN } from 'date-fns/locale/zh-CN'; -import { AlertCircle, Boxes, Info, PackageOpen, RefreshCw, Search, User } from 'lucide-react'; +import { AlertCircle, Boxes, Info, RefreshCw, Search, User } from 'lucide-react'; import * as stylex from '@stylexjs/stylex'; import { Spinner } from '@/ui/spinner'; import { DEFAULT_PROJECT_SKILL_DIR, type ProjectSkill, type ProjectSkillScope } from '@lody/shared'; @@ -22,6 +22,7 @@ import { Input } from '@lody/ui/input'; import { Spinner as LoadingSpinner } from '@lody/ui/spinner'; import { colors } from '@lody/ui/tokens/colors.stylex'; import { corner, radius, space } from '@lody/ui/tokens/scales.stylex'; +import { SettingsEmptyList } from './compact-layout'; const MONO = 'var(--font-mono, ui-monospace, monospace)'; const TRUNCATE = { @@ -281,15 +282,15 @@ export function ProjectSkillsView({ ); } return ( - } - title={t('workspace.projects.skills.empty', 'No skills found')} - body={t('workspace.projects.skills.emptyHint', { + + > + {t('workspace.projects.skills.empty', 'No skills found')} + ); } diff --git a/packages/components/src/components/settings/surface.ts b/packages/components/src/components/settings/surface.ts index 540b523744..33eb04f375 100644 --- a/packages/components/src/components/settings/surface.ts +++ b/packages/components/src/components/settings/surface.ts @@ -327,13 +327,53 @@ export const settingsSurface = stylex.create({ lineHeight: 1.375, color: colors.secondaryLabel, }, - /** An empty list's one line when it carries the way to fill it. */ - cardNoteWithAction: { + /** Empty catalogs have no record card until there is something to list. */ + emptyState: { display: 'flex', + flexDirection: 'column', alignItems: 'center', - justifyContent: 'space-between', - gap: space[4], - paddingBlock: space[2], + justifyContent: 'center', + boxSizing: 'border-box', + minWidth: 0, + minHeight: '320px', + width: '100%', + gap: space[3], + paddingBlock: space[8], + paddingInline: space[4], + textAlign: 'center', + }, + emptyIllustration: { + flexShrink: 0, + width: '176px', + height: '176px', + maxWidth: '100%', + objectFit: 'contain', + userSelect: 'none', + pointerEvents: 'none', + }, + emptyTitle: { + margin: 0, + maxWidth: '44ch', + fontSize: text.bodySize, + fontWeight: 500, + lineHeight: 1.5, + color: colors.label, + overflowWrap: 'anywhere', + }, + emptyDescription: { + margin: 0, + maxWidth: '48ch', + fontSize: text.footnoteSize, + lineHeight: 1.6, + color: colors.secondaryLabel, + overflowWrap: 'anywhere', + }, + emptyAction: { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + gap: space[2], + paddingTop: space[1], }, /** A row whose control cannot be used here: its name steps back with it. */ rowLabelDisabled: { color: colors.tertiaryLabel }, diff --git a/packages/components/src/stories/DesktopSettingsModal.stories.tsx b/packages/components/src/stories/DesktopSettingsModal.stories.tsx index 4c7fc39ad3..8e913fb9fb 100644 --- a/packages/components/src/stories/DesktopSettingsModal.stories.tsx +++ b/packages/components/src/stories/DesktopSettingsModal.stories.tsx @@ -40,39 +40,56 @@ const storyRole: AgentRole = { }; // A read-only catalog fixture: no transport or real workspace writes. -const rolesStoryRuntime = { - workspaceId: 'settings-story-workspace', - workspaceSlug: 'lody', - repo: { - openFlockDoc: async () => ({ - flock: { - scan: (options?: { prefix?: readonly unknown[] }) => - options?.prefix?.[0] === 'agentRole' - ? [{ key: workspaceFlockKeys.agentRole(storyRole.id), value: storyRole }] - : [], - subscribe: () => () => {}, - }, - joinRoom: async () => ({ unsubscribe: () => {}, firstSyncedWithRemote: Promise.resolve() }), - }), - }, -} as unknown as WorkspaceRuntime; +function createCatalogStoryRuntime(roles: readonly AgentRole[]) { + return { + workspaceId: 'settings-story-workspace', + workspaceSlug: 'lody', + repo: { + openFlockDoc: async () => ({ + flock: { + scan: (options?: { prefix?: readonly unknown[] }) => + options?.prefix?.[0] === 'agentRole' + ? roles.map((role) => ({ key: workspaceFlockKeys.agentRole(role.id), value: role })) + : [], + subscribe: () => () => {}, + }, + joinRoom: async () => ({ unsubscribe: () => {}, firstSyncedWithRemote: Promise.resolve() }), + }), + }, + } as unknown as WorkspaceRuntime; +} + +const rolesStoryRuntime = createCatalogStoryRuntime([storyRole]); +const emptyCatalogStoryRuntime = createCatalogStoryRuntime([]); -function RolesCatalogFixture({ children }: { children: ReactNode }) { +function RolesCatalogFixture({ + children, + empty = false, +}: { + children: ReactNode; + empty?: boolean; +}) { useHydrateAtoms([ - [runtimeAtom, rolesStoryRuntime], + [runtimeAtom, empty ? emptyCatalogStoryRuntime : rolesStoryRuntime], [userAtom, { id: storyRole.ownerUserId, name: 'Example user', email: 'user@example.com' }], ]); return <>{children}; } -function SettingsModalStory({ tab }: { tab: SettingsTabId }) { +function SettingsModalStory({ + tab, + emptyCatalog = false, +}: { + tab: SettingsTabId; + emptyCatalog?: boolean; +}) { return ( - {tab === 'agent-roles' ? ( - + {tab === 'agent-roles' || emptyCatalog ? ( + ) : ( @@ -109,15 +126,19 @@ export const KeyboardShortcutsTab: Story = { render: () => , }; -/** An empty catalog: the list's own card with one quiet line. */ +/** An empty, hydrated catalog without a live workspace or personal data. */ export const McpEmptyTab: Story = { - render: () => , + render: () => , }; export const AgentRolesTab: Story = { render: () => , }; +export const AgentRolesEmptyTab: Story = { + render: () => , +}; + export const DarkModePreferencesTab: Story = { render: () => (
diff --git a/packages/components/src/stories/MobileSettingsPages.stories.tsx b/packages/components/src/stories/MobileSettingsPages.stories.tsx index 343482b555..e3c282d079 100644 --- a/packages/components/src/stories/MobileSettingsPages.stories.tsx +++ b/packages/components/src/stories/MobileSettingsPages.stories.tsx @@ -3,6 +3,7 @@ import { fn } from 'storybook/test'; import type { ReactNode } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { useHydrateAtoms } from 'jotai/utils'; +import { useTranslation } from 'react-i18next'; import { createCapabilitySet, createLocalPlatformProvider, @@ -12,12 +13,15 @@ import { import { PlatformContext } from '@lody/platform/react'; import { currentWorkspaceSlugAtom, userAtom } from '@/atoms'; +import { runtimeAtom, type WorkspaceRuntime } from '@/atoms/runtime'; import { MobileAboutSettings } from '@/components/mobile/mobile-about-settings'; import { MobileAccountSettings } from '@/components/mobile/mobile-account-settings'; import { MobileAppearanceSettings } from '@/components/mobile/mobile-appearance-settings'; import { MobileGeneralSettings } from '@/components/mobile/mobile-general-settings'; import { MobileSettingsLayout } from '@/components/mobile/mobile-settings-layout'; import { SettingsCategoryList } from '@/components/settings/settings-category-list'; +import { AgentRolesSetting } from '@/components/settings/agent-roles-setting'; +import { McpSetting } from '@/components/settings/mcp-setting'; import { WorkspaceJoinRequestsSettings } from '@/components/settings/workspace-join-requests-settings'; import { StableSessionContext, type StableSessionValue } from '@/hooks/useStableSession'; import type { LodyAuthClient } from '@/lib/auth'; @@ -155,21 +159,32 @@ function StoryProviders({ children }: { children: ReactNode }) { ); } -function PhoneFrame({ title, children }: { title: string; children: ReactNode }) { +type PhoneFrameProps = { title: string; titleKey?: string; children: ReactNode }; + +function PhoneContent({ title, titleKey, children }: PhoneFrameProps) { + const { t } = useTranslation(); + return ( + + {children} + + ); +} + +function PhoneFrame({ title, titleKey, children }: PhoneFrameProps) { return (
- + {children} - +
@@ -181,11 +196,50 @@ const meta = { title: 'Mobile/MobileSettingsPages', component: PhoneFrame, parameters: { layout: 'fullscreen' }, + args: { title: 'Settings', children: null }, } satisfies Meta; export default meta; type Story = StoryObj; +const emptyCatalogRuntime = { + workspaceId: storyOrganization.id, + workspaceSlug: storyOrganization.slug, + repo: { + openFlockDoc: async () => ({ + flock: { scan: () => [], subscribe: () => () => {} }, + joinRoom: async () => ({ unsubscribe: () => {}, firstSyncedWithRemote: Promise.resolve() }), + }), + }, +} as unknown as WorkspaceRuntime; + +function EmptyCatalogFixture({ children }: { children: ReactNode }) { + useHydrateAtoms([[runtimeAtom, emptyCatalogRuntime]]); + return <>{children}; +} + +function EmptyCatalogPage({ kind }: { kind: 'roles' | 'mcp' }) { + return ( + + + {kind === 'roles' ? : } + + + ); +} + +/** Real catalog pages in the mobile settings layout, backed by empty fixtures. */ +export const AgentRolesEmpty: Story = { + render: () => , +}; + +export const McpEmpty: Story = { + render: () => , +}; + /** Settings home — the iOS-style grouped category list. */ export const Home: Story = { args: { title: 'Settings', children: }, diff --git a/packages/components/src/stories/PromptShortcutsList.stories.tsx b/packages/components/src/stories/PromptShortcutsList.stories.tsx new file mode 100644 index 0000000000..b656024616 --- /dev/null +++ b/packages/components/src/stories/PromptShortcutsList.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; +import { PromptShortcutsList } from '@/components/settings/prompt-shortcuts-setting'; + +const meta = { + title: 'Settings/PromptShortcutsList', + component: PromptShortcutsList, + args: { + entries: [], + currentUserId: 'story-user', + loading: false, + busy: false, + canCreate: true, + onCreate: fn(), + onOpen: fn(), + onDelete: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = {}; +export const Loading: Story = { args: { loading: true } }; +export const ReadOnlyEmpty: Story = { args: { canCreate: false } }; diff --git a/packages/components/tests/e2e/desktop-settings-layout.spec.ts b/packages/components/tests/e2e/desktop-settings-layout.spec.ts index 9b2fbed16c..ba46821e84 100644 --- a/packages/components/tests/e2e/desktop-settings-layout.spec.ts +++ b/packages/components/tests/e2e/desktop-settings-layout.spec.ts @@ -3,6 +3,93 @@ import { OPEN_SOURCE_ATTRIBUTION_BUNDLE } from '../../src/lib/open-source-attrib const rolesStory = '/iframe.html?id=settings-desktopsettingsmodal--agent-roles-tab&viewMode=story'; +for (const { locale, theme, width, roleTitle, mcpTitle, addRole, addMcp } of [ + { + locale: 'en', + theme: 'light', + width: 1180, + roleTitle: 'No agent roles yet', + mcpTitle: 'No MCP servers are configured for this workspace.', + addRole: 'Add role', + addMcp: 'Add server', + }, + { + locale: 'zh_CN', + theme: 'dark', + width: 500, + roleTitle: '还没有 Agent 角色', + mcpTitle: '此工作区还没有配置 MCP 服务器。', + addRole: '添加角色', + addMcp: '添加服务器', + }, +]) { + test(`illustrated empty catalogs stay centered and keep add actions (${locale}, ${width}px)`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + const visit = async (story: string) => + page.goto(`/iframe.html?id=${story}&viewMode=story&globals=theme:${theme};locale:${locale}`); + for (const { story, title, add } of [ + { + story: 'settings-desktopsettingsmodal--agent-roles-empty-tab', + title: roleTitle, + add: addRole, + }, + { story: 'settings-desktopsettingsmodal--mcp-empty-tab', title: mcpTitle, add: addMcp }, + { story: 'settings-memorycatalog--empty' }, + { story: 'settings-memoryidentitylist--empty' }, + { story: 'settings-promptshortcutslist--empty' }, + { story: 'settings-projectskillstab--empty' }, + ]) { + await visit(story); + const empty = page.locator('[data-settings-empty-state]'); + await expect(empty).toBeVisible(); + const illustration = empty.locator('img'); + await expect(illustration).toHaveAttribute('alt', ''); + await expect(illustration).toHaveAttribute('aria-hidden', 'true'); + await illustration.evaluate((img: HTMLImageElement) => img.decode()); + const layout = await empty.evaluate((element) => { + const box = element.getBoundingClientRect(); + const image = element.querySelector('img')!.getBoundingClientRect(); + const copy = element.querySelector('p')!.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + imageCenter: image.x + image.width / 2, + copyCenter: copy.x + copy.width / 2, + center: box.x + box.width / 2, + imageBottom: image.bottom, + copyTop: copy.top, + shadow: style.boxShadow, + overflowY: style.overflowY, + width: element.clientWidth, + scrollWidth: element.scrollWidth, + }; + }); + expect(Math.abs(layout.imageCenter - layout.center)).toBeLessThanOrEqual(1); + expect(Math.abs(layout.copyCenter - layout.center)).toBeLessThanOrEqual(1); + expect(layout.copyTop).toBeGreaterThanOrEqual(layout.imageBottom); + expect(layout.shadow).toBe('none'); + expect(layout.overflowY).not.toMatch(/auto|scroll/); + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.width + 1); + if (title) await expect(empty.getByText(title, { exact: true })).toBeVisible(); + if (add) { + const action = page.getByRole('button', { name: add, exact: true }); + await expect(action).toHaveCount(1); + await action.click(); + await expect(page.getByRole('textbox').first()).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(empty).toBeVisible(); + } + } + await visit('settings-promptshortcutslist--loading'); + await expect(page.getByRole('status')).toBeVisible(); + await expect(page.locator('[data-settings-empty-state]')).toHaveCount(0); + await visit('settings-promptshortcutslist--read-only-empty'); + await expect(page.getByRole('button')).toBeDisabled(); + await expect(page.locator('[data-settings-empty-state]')).toBeVisible(); + }); +} + async function expectInside(element: Locator, container: Locator) { await expect(element).toBeVisible(); const outer = await container.boundingBox(); diff --git a/packages/components/tests/e2e/mobile-workspace-settings-layout.spec.ts b/packages/components/tests/e2e/mobile-workspace-settings-layout.spec.ts index 07d94b0603..fb890ca8d7 100644 --- a/packages/components/tests/e2e/mobile-workspace-settings-layout.spec.ts +++ b/packages/components/tests/e2e/mobile-workspace-settings-layout.spec.ts @@ -63,3 +63,34 @@ test('keeps join requests in the mobile section rhythm and centers the edit icon expect(editIconBox.height).toBeCloseTo(14, 5); expect(Math.abs(iconCenter - textCenter)).toBeLessThan(0.5); }); + +for (const theme of ['light', 'dark']) { + test(`keeps empty catalogs readable and actionable in mobile ${theme} settings`, async ({ page }) => { + for (const { story, title, add } of [ + { story: 'agent-roles-empty', title: 'Agent 角色', add: '添加角色' }, + { story: 'mcp-empty', title: 'MCP', add: '添加服务器' }, + ]) { + await page.goto( + `/iframe.html?id=mobile-mobilesettingspages--${story}&viewMode=story&globals=locale:zh_CN;theme:${theme}` + ); + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '返回', exact: true })).toBeVisible(); + const empty = page.locator('[data-settings-empty-state]'); + await expect(empty).toBeInViewport({ ratio: 1 }); + const illustration = empty.locator('img'); + await illustration.evaluate((img: HTMLImageElement) => img.decode()); + const body = page.getByRole('main'); + const box = await getVisibleBox(body); + const image = await getVisibleBox(illustration); + expect(Math.abs(image.x + image.width / 2 - (box.x + box.width / 2))).toBeLessThan(1); + expect(await body.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + await expect(empty.locator('p').last()).toBeInViewport({ ratio: 1 }); + const action = page.getByRole('button', { name: add, exact: true }); + await expect(action).toBeInViewport({ ratio: 1 }); + await action.tap(); + await expect(page.getByRole('textbox').first()).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(empty).toBeVisible(); + } + }); +}