feat(web): enhance navigation and settings functionality in DesignBrowserPanel and EntryShell

- Introduced a navigation stack in DesignBrowserPanel to manage back and forward navigation states.
- Updated the browser navigation logic to handle URL history and improve user experience.
- Added a settings menu in EntryShell for quick access to language and appearance options.
- Implemented CSS styles for the new settings menu, ensuring a consistent and user-friendly interface.
- Enhanced tests for navigation functionality and settings menu interactions.

These changes improve the overall usability of the application by streamlining navigation and providing easy access to settings.
This commit is contained in:
pftom 2026-05-31 16:42:33 +08:00
parent 45d453996e
commit bc336bf14b
6 changed files with 705 additions and 83 deletions

View file

@ -4,7 +4,9 @@ import {
useMemo,
useRef,
useState,
type ButtonHTMLAttributes,
type FormEvent,
type ReactNode,
} from 'react';
import {
clearHostBrowserData,
@ -24,6 +26,11 @@ type BrowserHistoryEntry = {
visitCount: number;
};
type BrowserNavigationEntry = {
title: string;
url: string;
};
type ReferenceSite = {
label: string;
url: string;
@ -55,6 +62,7 @@ type WebviewElement = HTMLElement & {
goBack(): void;
goForward(): void;
isLoading(): boolean;
loadURL?(url: string): void | Promise<void>;
reload(): void;
reloadIgnoringCache(): void;
};
@ -168,15 +176,20 @@ export function DesignBrowserPanel({
const [currentUrl, setCurrentUrl] = useState(EMPTY_URL);
const [addressValue, setAddressValue] = useState('');
const [history, setHistory] = useState<BrowserHistoryEntry[]>(() => loadHistory(projectId));
const [navigationStack, setNavigationStack] = useState<BrowserNavigationEntry[]>([]);
const [navigationIndex, setNavigationIndex] = useState(-1);
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const [webviewNode, setWebviewNode] = useState<WebviewElement | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [savingAction, setSavingAction] = useState<'brief' | 'screenshot' | 'task' | null>(null);
const chromeRef = useRef<HTMLDivElement | null>(null);
const navigationStackRef = useRef<BrowserNavigationEntry[]>([]);
const navigationIndexRef = useRef(-1);
const pendingLoadTargetRef = useRef<string | null>(null);
const canGoBack = navigationIndex > 0;
const canGoForward = navigationIndex >= 0 && navigationIndex < navigationStack.length - 1;
const assignWebviewNode = useCallback((node: HTMLWebViewElement | null) => {
// Set `allowpopups` imperatively rather than as a JSX prop. React's DOM
// renderer does not treat `allowpopups` as a known boolean attribute, so
@ -193,8 +206,11 @@ export function DesignBrowserPanel({
setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL);
setAddressValue('');
setCanGoBack(false);
setCanGoForward(false);
setNavigationStack([]);
setNavigationIndex(-1);
navigationStackRef.current = [];
navigationIndexRef.current = -1;
pendingLoadTargetRef.current = null;
}, [projectId]);
useEffect(() => {
@ -233,33 +249,106 @@ export function DesignBrowserPanel({
});
}, []);
const setNavigationState = useCallback((stack: BrowserNavigationEntry[], index: number) => {
navigationStackRef.current = stack;
navigationIndexRef.current = index;
setNavigationStack(stack);
setNavigationIndex(index);
}, []);
const recordNavigation = useCallback((url: string, title?: string, options?: { replacePendingTarget?: boolean }) => {
if (url === EMPTY_URL) {
pendingLoadTargetRef.current = null;
setNavigationState([], -1);
return;
}
if (!isHistoryUrl(url)) return;
const stack = navigationStackRef.current;
const index = navigationIndexRef.current;
const nextTitle = title && title.trim() ? title.trim() : labelFromUrl(url);
const nextEntry: BrowserNavigationEntry = { title: nextTitle, url };
const updateEntry = (entries: BrowserNavigationEntry[], entryIndex: number) => {
const existing = entries[entryIndex];
const next = entries.slice();
next[entryIndex] = {
title: nextTitle || existing?.title || labelFromUrl(url),
url,
};
return next;
};
const currentEntry = index >= 0 ? stack[index] : undefined;
const pendingTarget = pendingLoadTargetRef.current;
const shouldReplacePending =
Boolean(options?.replacePendingTarget && pendingTarget && currentEntry && sameUrl(currentEntry.url, pendingTarget));
if (currentEntry && (sameUrl(currentEntry.url, url) || shouldReplacePending)) {
setNavigationState(updateEntry(stack, index), index);
if (options?.replacePendingTarget) pendingLoadTargetRef.current = null;
return;
}
const previousIndex = index - 1;
if (previousIndex >= 0 && sameUrl(stack[previousIndex]?.url ?? '', url)) {
setNavigationState(updateEntry(stack, previousIndex), previousIndex);
if (options?.replacePendingTarget) pendingLoadTargetRef.current = null;
return;
}
const nextIndex = index + 1;
if (nextIndex < stack.length && sameUrl(stack[nextIndex]?.url ?? '', url)) {
setNavigationState(updateEntry(stack, nextIndex), nextIndex);
if (options?.replacePendingTarget) pendingLoadTargetRef.current = null;
return;
}
const base = index >= 0 ? stack.slice(0, index + 1) : [];
const nextStack = [...base, nextEntry].slice(-HISTORY_LIMIT);
setNavigationState(nextStack, nextStack.length - 1);
if (options?.replacePendingTarget) pendingLoadTargetRef.current = null;
}, [setNavigationState]);
const updateCurrentNavigationTitle = useCallback((title?: string) => {
const trimmedTitle = title?.trim();
const index = navigationIndexRef.current;
if (!trimmedTitle || index < 0) return;
const stack = navigationStackRef.current;
const currentEntry = stack[index];
if (!currentEntry || currentEntry.title === trimmedTitle) return;
const nextStack = stack.slice();
nextStack[index] = { ...currentEntry, title: trimmedTitle };
setNavigationState(nextStack, index);
}, [setNavigationState]);
const navigateTo = useCallback((rawAddress: string) => {
const nextUrl = normalizeBrowserAddress(rawAddress);
pendingLoadTargetRef.current = isHistoryUrl(nextUrl) ? nextUrl : null;
setLoadUrl(nextUrl);
setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
setSuggestionsOpen(false);
setMenuOpen(false);
if (isHistoryUrl(nextUrl)) commitHistory(nextUrl);
}, [commitHistory]);
if (isHistoryUrl(nextUrl)) {
commitHistory(nextUrl);
recordNavigation(nextUrl);
} else if (nextUrl === EMPTY_URL) {
recordNavigation(nextUrl);
}
}, [commitHistory, recordNavigation]);
const updateNavigationState = useCallback((node: WebviewElement | null = webviewNode) => {
const updateLoadingState = useCallback((node: WebviewElement | null = webviewNode) => {
if (!node) {
setCanGoBack(false);
setCanGoForward(false);
setIsLoading(false);
return;
}
// Electron's <webview> throws ("The WebView must be attached to the DOM and
// the dom-ready event emitted before this method can be called") when
// canGoBack/canGoForward/isLoading run before the guest attaches. The mount
// effect calls this immediately, so guard like safeGetWebviewUrl/Title do.
// isLoading runs before the guest attaches. The mount effect calls this
// immediately, so guard like safeGetWebviewUrl/Title do.
try {
setCanGoBack(Boolean(node.canGoBack()));
setCanGoForward(Boolean(node.canGoForward()));
setIsLoading(Boolean(node.isLoading()));
} catch {
// Pre-dom-ready: keep the existing (default) navigation state.
// Pre-dom-ready: keep the existing loading state.
}
}, [webviewNode]);
@ -267,19 +356,26 @@ export function DesignBrowserPanel({
const node = webviewNode;
if (!node) return;
const syncFromWebview = (url?: string, title?: string) => {
const syncFromWebview = (url?: string, title?: string, options?: { recordNavigation?: boolean }) => {
const nextUrl = url || safeGetWebviewUrl(node);
if (nextUrl) {
setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
}
const nextTitle = title || safeGetWebviewTitle(node);
if (nextUrl) commitHistory(nextUrl, nextTitle);
updateNavigationState(node);
if (nextUrl) {
commitHistory(nextUrl, nextTitle);
if (options?.recordNavigation !== false) {
recordNavigation(nextUrl, nextTitle, { replacePendingTarget: true });
} else {
updateCurrentNavigationTitle(nextTitle);
}
}
updateLoadingState(node);
};
const onStart = () => {
setIsLoading(true);
updateNavigationState(node);
updateLoadingState(node);
};
const onStop = () => {
setIsLoading(false);
@ -292,13 +388,14 @@ export function DesignBrowserPanel({
};
const onTitle = (event: Event) => {
const titleEvent = event as WebviewTitleEvent;
syncFromWebview(undefined, titleEvent.title);
syncFromWebview(undefined, titleEvent.title, { recordNavigation: false });
};
const onFail = (event: Event) => {
const navigationEvent = event as WebviewNavigationEvent;
if (navigationEvent.isMainFrame === false) return;
setIsLoading(false);
updateNavigationState(node);
pendingLoadTargetRef.current = null;
updateLoadingState(node);
};
node.addEventListener('did-start-loading', onStart);
@ -308,7 +405,7 @@ export function DesignBrowserPanel({
node.addEventListener('page-title-updated', onTitle);
node.addEventListener('did-fail-load', onFail);
node.addEventListener('dom-ready', onStop);
updateNavigationState(node);
updateLoadingState(node);
return () => {
node.removeEventListener('did-start-loading', onStart);
node.removeEventListener('did-stop-loading', onStop);
@ -318,7 +415,7 @@ export function DesignBrowserPanel({
node.removeEventListener('did-fail-load', onFail);
node.removeEventListener('dom-ready', onStop);
};
}, [commitHistory, updateNavigationState, webviewNode]);
}, [commitHistory, recordNavigation, updateCurrentNavigationTitle, updateLoadingState, webviewNode]);
const suggestions = useMemo(() => {
const query = addressValue.trim().toLocaleLowerCase();
@ -463,6 +560,8 @@ export function DesignBrowserPanel({
setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL);
setAddressValue('');
setNavigationState([], -1);
pendingLoadTargetRef.current = null;
}
setMenuOpen(false);
}
@ -473,6 +572,33 @@ export function DesignBrowserPanel({
setMenuOpen(false);
}
function loadWebviewUrl(url: string) {
if (!webviewNode) {
setLoadUrl(url);
return;
}
try {
const result = webviewNode.loadURL?.(url);
if (result instanceof Promise) void result.catch(() => setLoadUrl(url));
else if (!webviewNode.loadURL) setLoadUrl(url);
} catch {
setLoadUrl(url);
}
}
function navigateHistoryBy(delta: -1 | 1) {
const targetIndex = navigationIndex + delta;
const entry = navigationStack[targetIndex];
if (!entry) return;
pendingLoadTargetRef.current = null;
setNavigationState(navigationStack.slice(), targetIndex);
setCurrentUrl(entry.url);
setAddressValue(entry.url);
setSuggestionsOpen(false);
setMenuOpen(false);
loadWebviewUrl(entry.url);
}
function reload(hard = false) {
if (isBlank) return;
if (webviewNode) {
@ -495,36 +621,28 @@ export function DesignBrowserPanel({
<section className="design-browser" aria-label="Design Browser">
<div className="db-chrome" ref={chromeRef}>
<div className="db-nav">
<button
type="button"
className="db-icon-btn"
aria-label="Back"
title="Back"
<IconTooltipButton
label="Go Back"
disabled={!canGoBack}
onClick={() => webviewNode?.goBack()}
onClick={() => navigateHistoryBy(-1)}
>
<Icon name="chevron-left" size={16} />
</button>
<button
type="button"
className="db-icon-btn"
aria-label="Forward"
title="Forward"
</IconTooltipButton>
<IconTooltipButton
label="Go Forward"
disabled={!canGoForward}
onClick={() => webviewNode?.goForward()}
onClick={() => navigateHistoryBy(1)}
>
<Icon name="chevron-right" size={16} />
</button>
<button
type="button"
className={`db-icon-btn ${isLoading ? 'is-spinning' : ''}`}
aria-label="Reload"
title="Reload"
</IconTooltipButton>
<IconTooltipButton
label={isLoading ? 'Loading...' : 'Reload'}
className={isLoading ? 'is-spinning' : ''}
disabled={isBlank}
onClick={() => reload(false)}
>
<Icon name="reload" size={15} />
</button>
</IconTooltipButton>
</div>
<form className="db-address-form" onSubmit={handleAddressSubmit}>
<Icon name="globe" size={15} />
@ -563,25 +681,19 @@ export function DesignBrowserPanel({
) : null}
</form>
<div className="db-actions">
<button
type="button"
className="db-icon-btn"
aria-label="Save page brief"
title="Save page brief"
<IconTooltipButton
label="Save page brief"
disabled={isBlank || savingAction != null}
onClick={savePageBrief}
>
<Icon name="file-code" size={15} />
</button>
<button
type="button"
className="db-icon-btn"
aria-label="Browser menu"
title="Browser menu"
</IconTooltipButton>
<IconTooltipButton
label="Browser menu"
onClick={() => setMenuOpen((open) => !open)}
>
<Icon name="more-horizontal" size={16} />
</button>
</IconTooltipButton>
{menuOpen ? (
<div className="db-menu" role="menu">
<button type="button" role="menuitem" onClick={takeScreenshot} disabled={isBlank || savingAction != null}>
@ -659,6 +771,29 @@ export function DesignBrowserPanel({
);
}
function IconTooltipButton({
label,
className,
children,
...buttonProps
}: {
label: string;
children: ReactNode;
} & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<span className="db-tooltip-anchor" data-tooltip={label}>
<button
{...buttonProps}
type="button"
className={['db-icon-btn', className].filter(Boolean).join(' ')}
aria-label={label}
>
{children}
</button>
</span>
);
}
function DesignBrowserStart({
onNavigate,
onSaveHarnessTask,

View file

@ -50,7 +50,13 @@ import type {
TrackingCliProviderId,
} from '@open-design/contracts/analytics';
import { agentIdToTracking } from '@open-design/contracts/analytics';
import { useT } from '../i18n';
import {
LOCALE_LABEL,
LOCALES,
useI18n,
useT,
type Locale,
} from '../i18n';
import { navigate, useRoute } from '../router';
import type {
AgentInfo,
@ -117,6 +123,9 @@ import {
} from './amrLoginPolling';
import { renderModelOptions } from './modelOptions';
const DISCORD_URL = 'https://discord.gg/mHAjSMV6gz';
const X_URL = 'https://x.com/nexudotio';
// The topbar chips (GitHub star, model switcher, Use everywhere)
// collapse into the settings dropdown when the viewport gets
// narrow. The transition is driven entirely by CSS @media queries
@ -214,7 +223,32 @@ function defaultPluginInputsForCreate(
};
}
// Theme options exposed in the avatar-popover appearance submenu.
// Quick theme options exposed in the entry settings menu.
type EntrySettingsSection =
| 'execution'
| 'media'
| 'composio'
| 'orbit'
| 'integrations'
| 'mcpClient'
| 'language'
| 'appearance'
| 'notifications'
| 'pet'
| 'library'
| 'about'
| 'memory'
| 'designSystems';
const ENTRY_THEME_OPTIONS: Array<{
value: AppTheme;
icon: 'sun-moon' | 'sun' | 'moon';
labelKey: 'settings.themeSystem' | 'settings.themeLight' | 'settings.themeDark';
}> = [
{ value: 'system', icon: 'sun-moon', labelKey: 'settings.themeSystem' },
{ value: 'light', icon: 'sun', labelKey: 'settings.themeLight' },
{ value: 'dark', icon: 'moon', labelKey: 'settings.themeDark' },
];
interface Props {
skills: SkillSummary[];
@ -295,23 +329,7 @@ interface Props {
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
onOpenSettings: (
section?:
| 'execution'
| 'media'
| 'composio'
| 'orbit'
| 'integrations'
| 'mcpClient'
| 'language'
| 'appearance'
| 'notifications'
| 'pet'
| 'library'
| 'about'
| 'memory'
| 'designSystems',
) => void;
onOpenSettings: (section?: EntrySettingsSection) => void;
onCompleteOnboarding: () => void;
}
@ -347,6 +365,184 @@ function navElementForView(
}
}
function EntrySettingsMenu({
config,
onThemeChange,
onOpenSettings,
}: {
config: AppConfig;
onThemeChange: (theme: AppTheme) => void;
onOpenSettings: (section?: EntrySettingsSection) => void;
}) {
const t = useT();
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const activeTheme = config.theme ?? 'system';
useEffect(() => {
if (!open) return;
const onClick = (event: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="entry-settings-menu" ref={wrapRef}>
<button
ref={triggerRef}
type="button"
className="settings-icon-btn"
onClick={() => setOpen((value) => !value)}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
aria-haspopup="menu"
aria-expanded={open}
data-testid="entry-settings-menu-trigger"
>
<Icon name="settings" size={17} />
</button>
{open ? (
<div
className="entry-settings-menu__popover"
role="menu"
aria-label={t('entry.openSettingsTitle')}
data-testid="entry-settings-menu"
>
<section className="entry-settings-menu__section">
<div className="entry-settings-menu__section-title">
<Icon name="languages" size={13} />
<span>{t('settings.language')}</span>
</div>
<div className="entry-settings-menu__language-grid">
{LOCALES.map((code) => {
const active = locale === code;
return (
<button
key={code}
type="button"
role="menuitemradio"
aria-checked={active}
className={`entry-settings-menu__choice${
active ? ' is-active' : ''
}`}
onClick={() => {
setLocale(code as Locale);
setOpen(false);
}}
>
<span>{LOCALE_LABEL[code]}</span>
{active ? <Icon name="check" size={12} /> : null}
</button>
);
})}
</div>
</section>
<section className="entry-settings-menu__section">
<div className="entry-settings-menu__section-title">
<Icon name="palette" size={13} />
<span>{t('settings.appearance')}</span>
</div>
<div className="entry-settings-menu__theme-row">
{ENTRY_THEME_OPTIONS.map((option) => {
const active = activeTheme === option.value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={active}
className={`entry-settings-menu__theme${
active ? ' is-active' : ''
}`}
onClick={() => {
onThemeChange(option.value);
setOpen(false);
}}
>
<Icon name={option.icon} size={13} />
<span>{t(option.labelKey)}</span>
</button>
);
})}
</div>
</section>
<div className="entry-settings-menu__divider" aria-hidden />
<a
className="entry-settings-menu__item"
href={DISCORD_URL}
target="_blank"
rel="noreferrer noopener"
role="menuitem"
onClick={() => setOpen(false)}
>
<span className="entry-settings-menu__item-icon" aria-hidden>
<Icon name="discord" size={14} />
</span>
<span>Join Discord</span>
<Icon name="external-link" size={12} className="entry-settings-menu__item-end" />
</a>
<a
className="entry-settings-menu__item"
href={X_URL}
target="_blank"
rel="noreferrer noopener"
role="menuitem"
onClick={() => setOpen(false)}
>
<span
className="entry-settings-menu__item-icon entry-settings-menu__x-mark"
aria-hidden
>
X
</span>
<span>Follow @nexudotio on X</span>
<Icon name="external-link" size={12} className="entry-settings-menu__item-end" />
</a>
<div className="entry-settings-menu__divider" aria-hidden />
<button
type="button"
className="entry-settings-menu__item entry-settings-menu__item--primary"
data-testid="entry-settings-open-details"
role="menuitem"
onClick={() => {
setOpen(false);
onOpenSettings();
}}
>
<span className="entry-settings-menu__item-icon" aria-hidden>
<Icon name="settings" size={14} />
</span>
<span>{t('avatar.settings')}</span>
<span className="entry-settings-menu__item-meta">
{t('homeHero.details')}
</span>
</button>
</div>
) : null}
</div>
);
}
export function EntryShell({
skills,
designTemplates,
@ -534,15 +730,11 @@ export function EntryShell({
}
const avatarMenu = (
<button
type="button"
className="settings-icon-btn"
onClick={() => onOpenSettings()}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
>
<Icon name="settings" size={17} />
</button>
<EntrySettingsMenu
config={config}
onThemeChange={onThemeChange}
onOpenSettings={onOpenSettings}
/>
);
@ -583,7 +775,7 @@ export function EntryShell({
<GithubStarBadge />
<a
className="entry-discord-badge"
href="https://discord.gg/mHAjSMV6gz"
href={DISCORD_URL}
aria-label="Join the Open Design Discord"
title="Join the Open Design Discord"
data-testid="entry-discord-badge"

View file

@ -633,6 +633,167 @@
gap: 8px;
}
.entry-settings-menu {
position: relative;
display: inline-flex;
}
.entry-settings-menu__popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 60;
width: 320px;
max-width: calc(100vw - 24px);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
box-shadow: var(--shadow-lg, 0 14px 36px rgba(0, 0, 0, 0.14));
}
.entry-settings-menu__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.entry-settings-menu__section-title {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 4px;
min-height: 20px;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 600;
}
.entry-settings-menu__language-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
max-height: 170px;
overflow-y: auto;
padding: 3px;
border: 1px solid var(--border-soft);
border-radius: 9px;
background: var(--bg-subtle);
scrollbar-width: thin;
}
.entry-settings-menu__choice,
.entry-settings-menu__theme {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--text);
min-width: 0;
min-height: 30px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
font-size: 12px;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease,
color 120ms ease;
}
.entry-settings-menu__choice span,
.entry-settings-menu__theme span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-settings-menu__choice:hover,
.entry-settings-menu__theme:hover {
background: var(--bg-panel);
border-color: var(--border);
color: var(--text-strong);
}
.entry-settings-menu__choice.is-active,
.entry-settings-menu__theme.is-active {
background: var(--bg-panel);
border-color: color-mix(in srgb, var(--accent) 26%, var(--border));
color: var(--accent-strong);
box-shadow: var(--shadow-xs);
}
.entry-settings-menu__theme-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 3px;
border: 1px solid var(--border-soft);
border-radius: 9px;
background: var(--bg-subtle);
}
.entry-settings-menu__divider {
height: 1px;
margin: 0 4px;
background: var(--border-soft);
}
.entry-settings-menu__item {
appearance: none;
min-height: 34px;
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
padding: 0 9px;
font-size: 12.5px;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease,
color 120ms ease;
}
.entry-settings-menu__item:hover {
background: var(--bg-subtle);
border-color: var(--border-soft);
color: var(--text-strong);
}
.entry-settings-menu__item-icon {
width: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--text-muted);
}
.entry-settings-menu__x-mark {
font-size: 12px;
font-weight: 700;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.entry-settings-menu__item-end {
margin-left: auto;
color: var(--text-faint);
flex: 0 0 auto;
}
.entry-settings-menu__item-meta {
margin-left: auto;
flex: 0 0 auto;
color: var(--text-muted);
font-size: 11.5px;
}
.entry-settings-menu__item--primary {
background: var(--bg-subtle);
border-color: var(--border-soft);
}
[dir='rtl'] .entry-settings-menu__popover {
right: auto;
left: 0;
}
[dir='rtl'] .entry-settings-menu__item {
text-align: right;
}
/* Stacked menu-item layout the collapsed model-switcher row
shows two lines of text inside a single `.avatar-item` so the
active mode + agent reads as a header with the active model

View file

@ -267,6 +267,36 @@
position: relative;
justify-content: flex-end;
}
.db-tooltip-anchor {
position: relative;
display: inline-flex;
}
.db-tooltip-anchor::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 7px);
left: 50%;
z-index: 300;
max-width: 220px;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-panel) 94%, var(--text));
color: var(--text);
box-shadow: var(--shadow-lg);
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translate(-50%, -2px);
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1), transform 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.db-tooltip-anchor:hover::after,
.db-tooltip-anchor:focus-within::after {
opacity: 1;
transform: translate(-50%, 0);
}
.db-icon-btn {
width: 30px;
height: 30px;

View file

@ -100,4 +100,37 @@ describe('DesignBrowserPanel <webview> navigation', () => {
fireEvent.submit(input.closest('form')!);
expect(webview.getAttribute('src')).toBe('https://unsplash.com');
});
it('derives back and forward availability from the committed navigation stack', () => {
const { container } = render(
<DesignBrowserPanel projectId="proj-webview-3" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'example.com' } });
fireEvent.submit(input.closest('form')!);
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
loadURL?: (url: string) => void;
};
const loadURL = vi.fn();
webview.loadURL = loadURL;
const backButton = screen.getByRole('button', { name: 'Go Back' }) as HTMLButtonElement;
const forwardButton = screen.getByRole('button', { name: 'Go Forward' }) as HTMLButtonElement;
expect(backButton.disabled).toBe(true);
expect(backButton.parentElement?.getAttribute('data-tooltip')).toBe('Go Back');
dispatchWebviewNavigate(webview, 'https://example.com/');
expect(backButton.disabled).toBe(true);
dispatchWebviewNavigate(webview, 'https://example.com/docs/');
expect(input.value).toBe('https://example.com/docs/');
expect(backButton.disabled).toBe(false);
expect(forwardButton.disabled).toBe(true);
fireEvent.click(backButton);
expect(loadURL).toHaveBeenCalledWith('https://example.com/');
expect(forwardButton.disabled).toBe(false);
});
});

View file

@ -102,6 +102,58 @@ function renderOnboarding(
return props;
}
function renderHome(
overrides: Partial<React.ComponentProps<typeof EntryShell>> = {},
) {
window.history.replaceState(null, '', '/');
const props: React.ComponentProps<typeof EntryShell> = {
skills: [],
designTemplates: [],
designSystems: [],
projects: [],
templates: [],
promptTemplates: [],
defaultDesignSystemId: null,
connectors: [],
connectorsLoading: false,
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
theme: 'system',
}),
agents: [cliAgent()],
daemonLive: true,
onModeChange: vi.fn(),
onAgentChange: vi.fn(),
onAgentModelChange: vi.fn(),
onApiProtocolChange: vi.fn(),
onApiModelChange: vi.fn(),
onConfigPersist: vi.fn(),
onRefreshAgents: vi.fn(() => [cliAgent()]),
onThemeChange: vi.fn(),
onCreateProject: vi.fn(),
onCreatePluginShareProject: vi.fn(),
onImportClaudeDesign: vi.fn(),
onOpenProject: vi.fn(),
onOpenLiveArtifact: vi.fn(),
onDeleteProject: vi.fn(),
onRenameProject: vi.fn(),
onChangeDefaultDesignSystem: vi.fn(),
onPersistComposioKey: vi.fn(),
onOpenSettings: vi.fn(),
onCompleteOnboarding: vi.fn(),
...overrides,
};
render(
<I18nProvider initial="en">
<EntryShell {...props} />
</I18nProvider>,
);
return props;
}
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
@ -112,6 +164,25 @@ beforeEach(() => {
globalThis.fetch = originalFetch;
});
describe('EntryShell settings menu', () => {
it('opens quick actions before opening the full settings dialog', () => {
const props = renderHome();
fireEvent.click(screen.getByTestId('entry-settings-menu-trigger'));
expect(props.onOpenSettings).not.toHaveBeenCalled();
expect(screen.getByTestId('entry-settings-menu')).toBeTruthy();
expect(screen.getByText('Language')).toBeTruthy();
expect(screen.getByText('Appearance')).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Join Discord/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Follow @nexudotio on X/i })).toBeTruthy();
fireEvent.click(screen.getByTestId('entry-settings-open-details'));
expect(props.onOpenSettings).toHaveBeenCalledWith();
});
});
describe('EntryShell onboarding Open Design AMR runtime', () => {
it('does not auto-select Open Design AMR when the AMR runtime is unavailable', async () => {
globalThis.fetch = vi.fn(async () =>