feat(web): enhance HandoffButton and DesignBrowserPanel with improved functionality and styling

- Updated HandoffButton to support framework-specific CLI prompts and improved local project path handling.
- Enhanced DesignBrowserPanel to manage browser history with favicon support and improved address display.
- Introduced new utility functions for formatting addresses and extracting hostnames.
- Refactored CSS styles for better layout and responsiveness across components.
- Added tests for new functionalities in HandoffButton and DesignBrowserPanel, ensuring robust behavior.

These changes improve user experience by streamlining the handoff process and enhancing the design browsing capabilities within the application.
This commit is contained in:
pftom 2026-05-31 17:20:59 +08:00
parent bc336bf14b
commit 1109bb15da
8 changed files with 1034 additions and 171 deletions

View file

@ -20,6 +20,7 @@ import {
import { Icon } from './Icon'; import { Icon } from './Icon';
type BrowserHistoryEntry = { type BrowserHistoryEntry = {
iconUrl?: string;
title: string; title: string;
url: string; url: string;
lastVisitedAt: number; lastVisitedAt: number;
@ -77,6 +78,10 @@ type WebviewTitleEvent = Event & {
title?: string; title?: string;
}; };
type WebviewFaviconEvent = Event & {
favicons?: string[];
};
interface DesignBrowserPanelProps { interface DesignBrowserPanelProps {
projectId: string; projectId: string;
onOpenFile: (name: string) => void; onOpenFile: (name: string) => void;
@ -86,6 +91,8 @@ interface DesignBrowserPanelProps {
const EMPTY_URL = 'about:blank'; const EMPTY_URL = 'about:blank';
const DESIGN_BROWSER_PARTITION = 'persist:open-design-design-browser'; const DESIGN_BROWSER_PARTITION = 'persist:open-design-design-browser';
const HISTORY_LIMIT = 80; const HISTORY_LIMIT = 80;
const HISTORY_SUGGESTION_LIMIT = 20;
const warmedOrigins = new Set<string>();
const REFERENCE_GROUPS: ReferenceGroup[] = [ const REFERENCE_GROUPS: ReferenceGroup[] = [
{ {
@ -175,6 +182,7 @@ export function DesignBrowserPanel({
const [loadUrl, setLoadUrl] = useState(EMPTY_URL); const [loadUrl, setLoadUrl] = useState(EMPTY_URL);
const [currentUrl, setCurrentUrl] = useState(EMPTY_URL); const [currentUrl, setCurrentUrl] = useState(EMPTY_URL);
const [addressValue, setAddressValue] = useState(''); const [addressValue, setAddressValue] = useState('');
const [addressEditing, setAddressEditing] = useState(false);
const [history, setHistory] = useState<BrowserHistoryEntry[]>(() => loadHistory(projectId)); const [history, setHistory] = useState<BrowserHistoryEntry[]>(() => loadHistory(projectId));
const [navigationStack, setNavigationStack] = useState<BrowserNavigationEntry[]>([]); const [navigationStack, setNavigationStack] = useState<BrowserNavigationEntry[]>([]);
const [navigationIndex, setNavigationIndex] = useState(-1); const [navigationIndex, setNavigationIndex] = useState(-1);
@ -184,6 +192,7 @@ export function DesignBrowserPanel({
const [webviewNode, setWebviewNode] = useState<WebviewElement | null>(null); const [webviewNode, setWebviewNode] = useState<WebviewElement | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [savingAction, setSavingAction] = useState<'brief' | 'screenshot' | 'task' | null>(null); const [savingAction, setSavingAction] = useState<'brief' | 'screenshot' | 'task' | null>(null);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const chromeRef = useRef<HTMLDivElement | null>(null); const chromeRef = useRef<HTMLDivElement | null>(null);
const navigationStackRef = useRef<BrowserNavigationEntry[]>([]); const navigationStackRef = useRef<BrowserNavigationEntry[]>([]);
const navigationIndexRef = useRef(-1); const navigationIndexRef = useRef(-1);
@ -206,6 +215,7 @@ export function DesignBrowserPanel({
setLoadUrl(EMPTY_URL); setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL); setCurrentUrl(EMPTY_URL);
setAddressValue(''); setAddressValue('');
setAddressEditing(false);
setNavigationStack([]); setNavigationStack([]);
setNavigationIndex(-1); setNavigationIndex(-1);
navigationStackRef.current = []; navigationStackRef.current = [];
@ -214,7 +224,8 @@ export function DesignBrowserPanel({
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
saveHistory(projectId, history); const timer = window.setTimeout(() => saveHistory(projectId, history), 140);
return () => window.clearTimeout(timer);
}, [history, projectId]); }, [history, projectId]);
useEffect(() => { useEffect(() => {
@ -235,15 +246,34 @@ export function DesignBrowserPanel({
return () => document.removeEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown);
}, [menuOpen, suggestionsOpen]); }, [menuOpen, suggestionsOpen]);
const commitHistory = useCallback((url: string, title?: string) => { const commitHistory = useCallback((url: string, meta: { title?: string; iconUrl?: string } = {}, options: { countVisit?: boolean } = {}) => {
if (!isHistoryUrl(url)) return; if (!isHistoryUrl(url)) return;
setHistory((current) => { setHistory((current) => {
const now = Date.now(); const now = Date.now();
const existing = current.find((entry) => sameUrl(entry.url, url)); const existing = current.find((entry) => sameUrl(entry.url, url));
const nextTitle = title && title.trim() ? title.trim() : labelFromUrl(url); const nextTitle = meta.title && meta.title.trim()
? meta.title.trim()
: existing?.title || labelFromUrl(url);
const nextIconUrl = cleanIconUrl(meta.iconUrl) || existing?.iconUrl || faviconUrl(url);
const visitIncrement = options.countVisit === false ? 0 : 1;
const entry = existing const entry = existing
? { ...existing, title: nextTitle, lastVisitedAt: now, visitCount: existing.visitCount + 1 } ? {
: { title: nextTitle, url, lastVisitedAt: now, visitCount: 1 }; ...existing,
iconUrl: nextIconUrl,
title: nextTitle,
lastVisitedAt: visitIncrement > 0 ? now : existing.lastVisitedAt,
visitCount: existing.visitCount + visitIncrement,
}
: { iconUrl: nextIconUrl, title: nextTitle, url, lastVisitedAt: now, visitCount: 1 };
if (
existing &&
existing.title === entry.title &&
existing.iconUrl === entry.iconUrl &&
existing.lastVisitedAt === entry.lastVisitedAt &&
existing.visitCount === entry.visitCount
) {
return current;
}
return [entry, ...current.filter((item) => !sameUrl(item.url, url))] return [entry, ...current.filter((item) => !sameUrl(item.url, url))]
.slice(0, HISTORY_LIMIT); .slice(0, HISTORY_LIMIT);
}); });
@ -320,21 +350,42 @@ export function DesignBrowserPanel({
setNavigationState(nextStack, index); setNavigationState(nextStack, index);
}, [setNavigationState]); }, [setNavigationState]);
const loadWebviewUrl = useCallback((url: string) => {
if (!webviewNode) {
setLoadUrl(url);
return;
}
if (loadUrl === EMPTY_URL) {
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);
}
}, [loadUrl, webviewNode]);
const navigateTo = useCallback((rawAddress: string) => { const navigateTo = useCallback((rawAddress: string) => {
const nextUrl = normalizeBrowserAddress(rawAddress); const nextUrl = normalizeBrowserAddress(rawAddress);
warmBrowserOrigin(nextUrl);
pendingLoadTargetRef.current = isHistoryUrl(nextUrl) ? nextUrl : null; pendingLoadTargetRef.current = isHistoryUrl(nextUrl) ? nextUrl : null;
setLoadUrl(nextUrl);
setCurrentUrl(nextUrl); setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl); setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
setAddressEditing(false);
setSuggestionsOpen(false); setSuggestionsOpen(false);
setMenuOpen(false); setMenuOpen(false);
if (isHistoryUrl(nextUrl)) { if (isHistoryUrl(nextUrl)) {
commitHistory(nextUrl); commitHistory(nextUrl, undefined, { countVisit: true });
recordNavigation(nextUrl); recordNavigation(nextUrl);
} else if (nextUrl === EMPTY_URL) { } else if (nextUrl === EMPTY_URL) {
setLoadUrl(EMPTY_URL);
recordNavigation(nextUrl); recordNavigation(nextUrl);
} }
}, [commitHistory, recordNavigation]); if (nextUrl !== EMPTY_URL) loadWebviewUrl(nextUrl);
}, [commitHistory, loadWebviewUrl, recordNavigation]);
const updateLoadingState = useCallback((node: WebviewElement | null = webviewNode) => { const updateLoadingState = useCallback((node: WebviewElement | null = webviewNode) => {
if (!node) { if (!node) {
@ -356,15 +407,21 @@ export function DesignBrowserPanel({
const node = webviewNode; const node = webviewNode;
if (!node) return; if (!node) return;
const syncFromWebview = (url?: string, title?: string, options?: { recordNavigation?: boolean }) => { const syncFromWebview = (
url?: string,
title?: string,
options?: { iconUrl?: string; recordNavigation?: boolean; recordVisit?: boolean },
) => {
const nextUrl = url || safeGetWebviewUrl(node); const nextUrl = url || safeGetWebviewUrl(node);
if (nextUrl) { if (nextUrl) {
setCurrentUrl(nextUrl); setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl); if (!addressEditing) {
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
}
} }
const nextTitle = title || safeGetWebviewTitle(node); const nextTitle = title || safeGetWebviewTitle(node);
if (nextUrl) { if (nextUrl) {
commitHistory(nextUrl, nextTitle); commitHistory(nextUrl, { iconUrl: options?.iconUrl, title: nextTitle }, { countVisit: options?.recordVisit === true });
if (options?.recordNavigation !== false) { if (options?.recordNavigation !== false) {
recordNavigation(nextUrl, nextTitle, { replacePendingTarget: true }); recordNavigation(nextUrl, nextTitle, { replacePendingTarget: true });
} else { } else {
@ -379,16 +436,25 @@ export function DesignBrowserPanel({
}; };
const onStop = () => { const onStop = () => {
setIsLoading(false); setIsLoading(false);
syncFromWebview(); syncFromWebview(undefined, undefined, { recordVisit: false });
}; };
const onNavigate = (event: Event) => { const onNavigate = (event: Event) => {
const navigationEvent = event as WebviewNavigationEvent; const navigationEvent = event as WebviewNavigationEvent;
if (navigationEvent.isMainFrame === false) return; if (navigationEvent.isMainFrame === false) return;
syncFromWebview(navigationEvent.url); const pendingTarget = pendingLoadTargetRef.current;
const nextUrl = navigationEvent.url || safeGetWebviewUrl(node);
const isPendingCommit = Boolean(pendingTarget && nextUrl && sameUrl(pendingTarget, nextUrl));
syncFromWebview(nextUrl, undefined, { recordVisit: !isPendingCommit });
}; };
const onTitle = (event: Event) => { const onTitle = (event: Event) => {
const titleEvent = event as WebviewTitleEvent; const titleEvent = event as WebviewTitleEvent;
syncFromWebview(undefined, titleEvent.title, { recordNavigation: false }); syncFromWebview(undefined, titleEvent.title, { recordNavigation: false, recordVisit: false });
};
const onFavicon = (event: Event) => {
const faviconEvent = event as WebviewFaviconEvent;
const iconUrl = faviconEvent.favicons?.find(isHttpLikeUrl);
if (!iconUrl) return;
syncFromWebview(undefined, undefined, { iconUrl, recordNavigation: false, recordVisit: false });
}; };
const onFail = (event: Event) => { const onFail = (event: Event) => {
const navigationEvent = event as WebviewNavigationEvent; const navigationEvent = event as WebviewNavigationEvent;
@ -403,6 +469,7 @@ export function DesignBrowserPanel({
node.addEventListener('did-navigate', onNavigate); node.addEventListener('did-navigate', onNavigate);
node.addEventListener('did-navigate-in-page', onNavigate); node.addEventListener('did-navigate-in-page', onNavigate);
node.addEventListener('page-title-updated', onTitle); node.addEventListener('page-title-updated', onTitle);
node.addEventListener('page-favicon-updated', onFavicon);
node.addEventListener('did-fail-load', onFail); node.addEventListener('did-fail-load', onFail);
node.addEventListener('dom-ready', onStop); node.addEventListener('dom-ready', onStop);
updateLoadingState(node); updateLoadingState(node);
@ -412,39 +479,46 @@ export function DesignBrowserPanel({
node.removeEventListener('did-navigate', onNavigate); node.removeEventListener('did-navigate', onNavigate);
node.removeEventListener('did-navigate-in-page', onNavigate); node.removeEventListener('did-navigate-in-page', onNavigate);
node.removeEventListener('page-title-updated', onTitle); node.removeEventListener('page-title-updated', onTitle);
node.removeEventListener('page-favicon-updated', onFavicon);
node.removeEventListener('did-fail-load', onFail); node.removeEventListener('did-fail-load', onFail);
node.removeEventListener('dom-ready', onStop); node.removeEventListener('dom-ready', onStop);
}; };
}, [commitHistory, recordNavigation, updateCurrentNavigationTitle, updateLoadingState, webviewNode]); }, [addressEditing, commitHistory, recordNavigation, updateCurrentNavigationTitle, updateLoadingState, webviewNode]);
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
const query = addressValue.trim().toLocaleLowerCase(); const query = addressValue.trim().toLocaleLowerCase();
const showDefaultSuggestions = addressEditing && currentUrl !== EMPTY_URL && sameUrl(addressValue.trim(), currentUrl);
const referenceSuggestions = REFERENCE_GROUPS.flatMap((group) => const referenceSuggestions = REFERENCE_GROUPS.flatMap((group) =>
group.sites.map((site) => ({ group.sites.map((site) => ({
detail: `${group.title} - ${site.detail}`, detail: `${group.title} - ${site.detail}`,
id: `site:${site.url}`, id: `site:${site.url}`,
iconUrl: faviconUrl(site.url),
label: site.label, label: site.label,
type: 'Reference' as const, type: 'Reference' as const,
url: site.url, url: site.url,
})), })),
); );
const historySuggestions = history.map((entry) => ({ const historySuggestions = history.slice(0, HISTORY_SUGGESTION_LIMIT).map((entry) => ({
detail: entry.url, detail: entry.url,
id: `history:${entry.url}`, id: `history:${entry.url}`,
iconUrl: entry.iconUrl || faviconUrl(entry.url),
label: entry.title || labelFromUrl(entry.url), label: entry.title || labelFromUrl(entry.url),
type: 'History' as const, type: 'History' as const,
url: entry.url, url: entry.url,
})); }));
const all = [...historySuggestions, ...referenceSuggestions]; const all = [...historySuggestions, ...referenceSuggestions];
if (!query) return all.slice(0, 12); if (!query || showDefaultSuggestions) return all;
return all return all
.filter((item) => .filter((item) =>
`${item.label} ${item.url} ${item.detail}`.toLocaleLowerCase().includes(query), `${item.label} ${item.url} ${item.detail}`.toLocaleLowerCase().includes(query),
) )
.slice(0, 12); .slice(0, HISTORY_SUGGESTION_LIMIT + referenceSuggestions.length);
}, [addressValue, history]); }, [addressEditing, addressValue, currentUrl, history]);
const pageTitle = history.find((entry) => sameUrl(entry.url, currentUrl))?.title || labelFromUrl(currentUrl); const pageHistoryEntry = history.find((entry) => sameUrl(entry.url, currentUrl));
const pageTitle = pageHistoryEntry?.title || labelFromUrl(currentUrl);
const pageIconUrl = pageHistoryEntry?.iconUrl || faviconUrl(currentUrl);
const shownAddressValue = addressEditing ? addressValue : formatAddressDisplay(currentUrl, pageTitle);
// Drive the start-page/webview branch off the load target, not the committed // Drive the start-page/webview branch off the load target, not the committed
// URL, so a transient about:blank navigation event can't unmount the webview. // URL, so a transient about:blank navigation event can't unmount the webview.
const isBlank = loadUrl === EMPTY_URL; const isBlank = loadUrl === EMPTY_URL;
@ -452,6 +526,7 @@ export function DesignBrowserPanel({
async function handleAddressSubmit(event: FormEvent<HTMLFormElement>) { async function handleAddressSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
navigateTo(addressValue); navigateTo(addressValue);
addressInputRef.current?.blur();
} }
async function copyCurrentUrl() { async function copyCurrentUrl() {
@ -560,32 +635,21 @@ export function DesignBrowserPanel({
setLoadUrl(EMPTY_URL); setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL); setCurrentUrl(EMPTY_URL);
setAddressValue(''); setAddressValue('');
setAddressEditing(false);
setNavigationState([], -1); setNavigationState([], -1);
pendingLoadTargetRef.current = null; pendingLoadTargetRef.current = null;
saveHistory(projectId, []);
} }
setMenuOpen(false); setMenuOpen(false);
} }
function clearHistoryOnly() { function clearHistoryOnly() {
setHistory([]); setHistory([]);
saveHistory(projectId, []);
setStatusMessage('History cleared'); setStatusMessage('History cleared');
setMenuOpen(false); 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) { function navigateHistoryBy(delta: -1 | 1) {
const targetIndex = navigationIndex + delta; const targetIndex = navigationIndex + delta;
const entry = navigationStack[targetIndex]; const entry = navigationStack[targetIndex];
@ -594,9 +658,15 @@ export function DesignBrowserPanel({
setNavigationState(navigationStack.slice(), targetIndex); setNavigationState(navigationStack.slice(), targetIndex);
setCurrentUrl(entry.url); setCurrentUrl(entry.url);
setAddressValue(entry.url); setAddressValue(entry.url);
setAddressEditing(false);
setSuggestionsOpen(false); setSuggestionsOpen(false);
setMenuOpen(false); setMenuOpen(false);
loadWebviewUrl(entry.url); if (webviewNode && canUseNativeHistoryNavigation(webviewNode, delta)) {
if (delta < 0) webviewNode.goBack();
else webviewNode.goForward();
} else {
loadWebviewUrl(entry.url);
}
} }
function reload(hard = false) { function reload(hard = false) {
@ -645,14 +715,30 @@ export function DesignBrowserPanel({
</IconTooltipButton> </IconTooltipButton>
</div> </div>
<form className="db-address-form" onSubmit={handleAddressSubmit}> <form className="db-address-form" onSubmit={handleAddressSubmit}>
<Icon name="globe" size={15} /> <BrowserSiteIcon
className="db-address-site-icon"
fallback="globe"
iconUrl={isBlank ? undefined : pageIconUrl}
/>
<input <input
value={addressValue} ref={addressInputRef}
value={shownAddressValue}
onChange={(event) => { onChange={(event) => {
setAddressEditing(true);
setAddressValue(event.target.value); setAddressValue(event.target.value);
setSuggestionsOpen(true); setSuggestionsOpen(true);
}} }}
onFocus={() => setSuggestionsOpen(true)} onFocus={(event) => {
setAddressEditing(true);
setAddressValue(isBlank ? '' : currentUrl);
setSuggestionsOpen(true);
const input = event.currentTarget;
window.requestAnimationFrame(() => input.select());
}}
onBlur={(event) => {
if (event.currentTarget.form?.contains(event.relatedTarget as Node | null)) return;
window.setTimeout(() => setAddressEditing(false), 80);
}}
placeholder="Enter URL or search..." placeholder="Enter URL or search..."
aria-label="Browser address" aria-label="Browser address"
autoComplete="off" autoComplete="off"
@ -665,10 +751,15 @@ export function DesignBrowserPanel({
key={item.id} key={item.id}
type="button" type="button"
role="option" role="option"
onFocus={() => warmBrowserOrigin(item.url)}
onPointerEnter={() => warmBrowserOrigin(item.url)}
onClick={() => navigateTo(item.url)} onClick={() => navigateTo(item.url)}
> >
<span className="db-suggestion-icon"> <span className="db-suggestion-icon">
<Icon name={item.type === 'History' ? 'history' : 'sparkles'} size={14} /> <BrowserSiteIcon
fallback={item.type === 'History' ? 'history' : 'globe'}
iconUrl={item.iconUrl}
/>
</span> </span>
<span className="db-suggestion-copy"> <span className="db-suggestion-copy">
<span>{item.label}</span> <span>{item.label}</span>
@ -757,13 +848,6 @@ export function DesignBrowserPanel({
) : ( ) : (
<div className="db-fallback"> <div className="db-fallback">
<iframe title={pageTitle} src={loadUrl} /> <iframe title={pageTitle} src={loadUrl} />
<div className="db-fallback-bar">
<span>Embedded browser controls are available in the desktop app.</span>
<button type="button" onClick={openCurrentExternally}>
<Icon name="external-link" size={14} />
Open
</button>
</div>
</div> </div>
)} )}
</div> </div>
@ -808,14 +892,13 @@ function DesignBrowserStart({
<div className="db-start-hero"> <div className="db-start-hero">
<div> <div>
<div className="db-kicker">Open Design browser</div> <div className="db-kicker">Open Design browser</div>
<h2>Reference, extract, apply.</h2> <h2>Reference Board</h2>
</div> </div>
<div className="db-agent-card"> <div className="db-agent-card">
<div className="db-agent-card-title"> <div className="db-agent-card-title">
<Icon name="sparkles" size={15} /> <Icon name="sparkles" size={15} />
Browser Harness Browser Harness
</div> </div>
<p>Turn any opened reference into a saved extraction task for browser-use and artifacts.</p>
</div> </div>
</div> </div>
<div className="db-reference-grid"> <div className="db-reference-grid">
@ -824,10 +907,21 @@ function DesignBrowserStart({
<h3>{group.title}</h3> <h3>{group.title}</h3>
<div className="db-reference-list"> <div className="db-reference-list">
{group.sites.map((site) => ( {group.sites.map((site) => (
<article key={site.url} className="db-reference-card"> <article
key={site.url}
className="db-reference-card"
onPointerEnter={() => warmBrowserOrigin(site.url)}
>
<button type="button" onClick={() => onNavigate(site.url)}> <button type="button" onClick={() => onNavigate(site.url)}>
<span>{site.label}</span> <BrowserSiteIcon
<small>{new URL(site.url).hostname.replace(/^www\./, '')}</small> className="db-reference-icon"
fallback="globe"
iconUrl={faviconUrl(site.url)}
/>
<span className="db-reference-title">
<span>{site.label}</span>
<small>{hostnameFromUrl(site.url)}</small>
</span>
</button> </button>
<p>{site.detail}</p> <p>{site.detail}</p>
<div className="db-reference-actions"> <div className="db-reference-actions">
@ -854,6 +948,28 @@ function DesignBrowserStart({
); );
} }
function BrowserSiteIcon({
className,
fallback,
iconUrl,
}: {
className?: string;
fallback: 'globe' | 'history';
iconUrl?: string;
}) {
const [failed, setFailed] = useState(false);
const cleanUrl = cleanIconUrl(iconUrl);
return (
<span className={['db-site-icon', className].filter(Boolean).join(' ')}>
{cleanUrl && !failed ? (
<img alt="" src={cleanUrl} onError={() => setFailed(true)} />
) : (
<Icon name={fallback} size={13} />
)}
</span>
);
}
export function loadHistory(projectId: string): BrowserHistoryEntry[] { export function loadHistory(projectId: string): BrowserHistoryEntry[] {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
try { try {
@ -889,7 +1005,8 @@ export function isHistoryEntry(value: unknown): value is BrowserHistoryEntry {
typeof record.url === 'string' && typeof record.url === 'string' &&
typeof record.title === 'string' && typeof record.title === 'string' &&
typeof record.lastVisitedAt === 'number' && typeof record.lastVisitedAt === 'number' &&
typeof record.visitCount === 'number' typeof record.visitCount === 'number' &&
(record.iconUrl === undefined || typeof record.iconUrl === 'string')
); );
} }
@ -920,6 +1037,32 @@ export function labelFromUrl(url: string): string {
} }
} }
export function formatAddressDisplay(url: string, title?: string): string {
if (url === EMPTY_URL) return '';
const cleanTitle = title?.trim();
if (!cleanTitle) return url;
const fallback = labelFromUrl(url);
if (cleanTitle === fallback || cleanTitle === url) return url;
return `${url} / ${cleanTitle}`;
}
export function hostnameFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
export function faviconUrl(url: string): string | undefined {
if (!isHttpLikeUrl(url)) return undefined;
try {
return new URL('/favicon.ico', new URL(url).origin).toString();
} catch {
return undefined;
}
}
export function isHistoryUrl(url: string): boolean { export function isHistoryUrl(url: string): boolean {
return url !== EMPTY_URL && (isHttpLikeUrl(url) || /^file:\/\//i.test(url)); return url !== EMPTY_URL && (isHttpLikeUrl(url) || /^file:\/\//i.test(url));
} }
@ -948,6 +1091,41 @@ function safeGetWebviewTitle(node: WebviewElement): string {
} }
} }
function cleanIconUrl(url?: string): string | undefined {
const value = url?.trim();
if (!value) return undefined;
if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return value;
return undefined;
}
function warmBrowserOrigin(url: string): void {
if (typeof document === 'undefined' || !isHttpLikeUrl(url)) return;
let origin: string;
try {
origin = new URL(url).origin;
} catch {
return;
}
if (warmedOrigins.has(origin)) return;
warmedOrigins.add(origin);
for (const rel of ['dns-prefetch', 'preconnect']) {
const link = document.createElement('link');
link.rel = rel;
link.href = origin;
if (rel === 'preconnect') link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}
}
function canUseNativeHistoryNavigation(node: WebviewElement, delta: -1 | 1): boolean {
try {
if (delta < 0) return typeof node.canGoBack === 'function' && node.canGoBack();
return typeof node.canGoForward === 'function' && node.canGoForward();
} catch {
return false;
}
}
export function browserFileName(prefix: string, url: string, extension: 'md' | 'png'): string { export function browserFileName(prefix: string, url: string, extension: 'md' | 'png'): string {
const host = labelFromUrl(url).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'page'; const host = labelFromUrl(url).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'page';
const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const stamp = new Date().toISOString().replace(/[:.]/g, '-');

View file

@ -1,24 +1,108 @@
// Hand-off menu in the ChatPane header — "open the design project // Hand-off menu in the ChatPane header. The left split button opens the
// folder in <local app>". Mirrors paseo's WorkspaceOpenInEditorButton: // current design project folder in a local editor, while the dropdown also
// a single split-style button that remembers the user's last pick // exposes copy-to-CLI prompts for handing the same local folder to code agents.
// (LocalStorage) and a dropdown listing the rest. Detection runs on
// the daemon; this component just renders.
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
AgentInfo,
HostEditor, HostEditor,
HostEditorId, HostEditorId,
HostEditorsResponse, HostEditorsResponse,
} from '@open-design/contracts'; } from '@open-design/contracts';
import { fetchHostEditors, openProjectInEditor } from '../providers/registry'; import { fetchHostEditors, openProjectInEditor } from '../providers/registry';
import { useT } from '../i18n'; import { useI18n } from '../i18n';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { EditorIcon } from './EditorIcon'; import { EditorIcon } from './EditorIcon';
import { AgentIcon } from './AgentIcon';
const PREFERRED_EDITOR_KEY = 'open-design:preferred-editor'; const PREFERRED_EDITOR_KEY = 'open-design:preferred-editor';
const PREFERRED_FRAMEWORK_KEY = 'open-design:handoff-framework';
interface FrameworkTarget {
id: string;
label: string;
promptLabel: string;
}
const FRAMEWORKS: FrameworkTarget[] = [
{ id: 'react', label: 'React', promptLabel: 'React' },
{ id: 'vue', label: 'Vue.js', promptLabel: 'Vue.js' },
{ id: 'svelte', label: 'Svelte', promptLabel: 'Svelte' },
{ id: 'solid', label: 'SolidJS', promptLabel: 'SolidJS' },
{ id: 'next', label: 'Next.js', promptLabel: 'Next.js / React' },
{ id: 'vanilla', label: 'JS', promptLabel: 'vanilla JavaScript, HTML, and CSS' },
];
const DEFAULT_FRAMEWORK: FrameworkTarget = FRAMEWORKS[0] ?? {
id: 'react',
label: 'React',
promptLabel: 'React',
};
interface CliTarget {
id: string;
name: string;
bin: string;
available: boolean;
version?: string | null;
}
const CLI_ORDER = [
'claude',
'codex',
'opencode',
'cursor-agent',
'gemini',
'qwen',
'qoder',
'copilot',
'grok-build',
'deepseek',
'kimi',
'hermes',
'devin',
'kiro',
'kilo',
'vibe',
'antigravity',
'aider',
'amr',
'trae-cli',
'pi',
'reasonix',
];
const FALLBACK_CLI_TARGETS: CliTarget[] = [
{ id: 'claude', name: 'Claude Code', bin: 'claude', available: false },
{ id: 'codex', name: 'Codex CLI', bin: 'codex', available: false },
{ id: 'opencode', name: 'OpenCode', bin: 'opencode-cli', available: false },
{ id: 'cursor-agent', name: 'Cursor Agent', bin: 'cursor-agent', available: false },
{ id: 'gemini', name: 'Gemini CLI', bin: 'gemini', available: false },
{ id: 'qwen', name: 'Qwen Code', bin: 'qwen', available: false },
{ id: 'qoder', name: 'Qoder CLI', bin: 'qodercli', available: false },
{ id: 'copilot', name: 'GitHub Copilot CLI', bin: 'copilot', available: false },
{ id: 'grok-build', name: 'Grok Build', bin: 'grok', available: false },
{ id: 'deepseek', name: 'DeepSeek TUI', bin: 'deepseek', available: false },
{ id: 'kimi', name: 'Kimi CLI', bin: 'kimi', available: false },
{ id: 'hermes', name: 'Hermes', bin: 'hermes', available: false },
{ id: 'devin', name: 'Devin for Terminal', bin: 'devin', available: false },
{ id: 'kiro', name: 'Kiro CLI', bin: 'kiro-cli', available: false },
{ id: 'kilo', name: 'Kilo', bin: 'kilo', available: false },
{ id: 'vibe', name: 'Mistral Vibe CLI', bin: 'vibe-acp', available: false },
{ id: 'antigravity', name: 'Antigravity', bin: 'agy', available: false },
{ id: 'aider', name: 'Aider', bin: 'aider', available: false },
{ id: 'amr', name: 'Open Design AMR', bin: 'vela', available: false },
{ id: 'trae-cli', name: 'Trae CLI', bin: 'traecli', available: false },
{ id: 'pi', name: 'Pi', bin: 'pi', available: false },
{ id: 'reasonix', name: 'DeepSeek Reasonix', bin: 'reasonix', available: false },
];
interface Props { interface Props {
projectId: string; projectId: string;
projectName?: string;
projectDir?: string | null;
agents?: AgentInfo[];
// Optional fallback "always open in OS file manager" — falls back to the // Optional fallback "always open in OS file manager" — falls back to the
// existing shell.openPath bridge in case the daemon catalogue is empty // existing shell.openPath bridge in case the daemon catalogue is empty
// (highly unlikely on macOS / Win / Linux but harmless to support). // (highly unlikely on macOS / Win / Linux but harmless to support).
@ -42,15 +126,187 @@ function writePreferred(id: HostEditorId): void {
} }
} }
export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) { function readPreferredFramework(): string {
const t = useT(); if (typeof window === 'undefined') return DEFAULT_FRAMEWORK.id;
try {
const stored = window.localStorage.getItem(PREFERRED_FRAMEWORK_KEY);
if (stored && FRAMEWORKS.some((f) => f.id === stored)) return stored;
} catch {
// ignore
}
return DEFAULT_FRAMEWORK.id;
}
function writePreferredFramework(id: string): void {
try {
window.localStorage.setItem(PREFERRED_FRAMEWORK_KEY, id);
} catch {
// ignore — quota or sandboxed
}
}
function cliDisplayName(agent: Pick<CliTarget, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
}
function mergeCliTargets(agents: AgentInfo[] | undefined): CliTarget[] {
const byId = new Map<string, CliTarget>();
for (const target of FALLBACK_CLI_TARGETS) {
byId.set(target.id, target);
}
for (const agent of agents ?? []) {
byId.set(agent.id, {
id: agent.id,
name: cliDisplayName(agent),
bin: agent.bin,
available: agent.available,
version: agent.version,
});
}
return [...byId.values()].sort((a, b) => {
const ai = CLI_ORDER.indexOf(a.id);
const bi = CLI_ORDER.indexOf(b.id);
const ao = ai === -1 ? Number.MAX_SAFE_INTEGER : ai;
const bo = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
if (ao !== bo) return ao - bo;
return cliDisplayName(a).localeCompare(cliDisplayName(b));
});
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function uiCopy(locale: string) {
if (locale === 'zh-TW') {
return {
editorSection: '透過編輯器開啟',
cliSection: '複製給 CLI',
framework: '目標框架',
copyPrompt: '複製提示詞',
copied: '已複製',
notInstalled: '未安裝',
unavailablePath: '尚未取得專案本機路徑,請稍後再試。',
copyFailed: '瀏覽器拒絕寫入剪貼簿,請稍後再試。',
promptIntro: '請基於這個 Open Design 專案的本機資料夾繼續實作:',
target: '目標',
stepsLead: '你現在是在 {cli} 中接手,請:',
readFiles: '先進入或讀取這個目錄,優先閱讀 DESIGN.md、README、現有 HTML/CSS/JS、素材和 package.json如果存在。',
keepDesign: '保持目前的視覺、佈局、互動和素材,不要只描述方案。',
produceCode: '產出或修改真實可執行的 {framework} 程式碼;如果專案已有更明確的工程棧,先說明衝突並優先保持可執行。',
verify: '完成後告訴我執行、預覽和驗證命令。',
commandHint: '如果要先切到專案目錄,可以用:',
project: '專案',
};
}
if (locale.startsWith('zh')) {
return {
editorSection: '通过编辑器打开',
cliSection: '复制给 CLI',
framework: '目标框架',
copyPrompt: '复制提示词',
copied: '已复制',
notInstalled: '未安装',
unavailablePath: '还没有拿到项目本地路径,请稍后再试。',
copyFailed: '浏览器拒绝写入剪贴板,请稍后再试。',
promptIntro: '请基于这个 Open Design 项目的本地文件夹继续实现:',
target: '目标',
stepsLead: '你现在是在 {cli} 中接手,请:',
readFiles: '先进入或读取这个目录,优先阅读 DESIGN.md、README、现有 HTML/CSS/JS、素材和 package.json如果存在。',
keepDesign: '保持现有视觉、布局、交互和素材,不要只描述方案。',
produceCode: '生成或修改真实可运行的 {framework} 代码;如果项目已有更明确的工程栈,先说明冲突并优先保持可运行。',
verify: '完成后告诉我运行、预览和验证命令。',
commandHint: '如果要先切到项目目录,可以用:',
project: '项目',
};
}
return {
editorSection: 'Open with editor',
cliSection: 'Copy for CLI',
framework: 'Target stack',
copyPrompt: 'Copy prompt',
copied: 'Copied',
notInstalled: 'Not installed',
unavailablePath: 'Project path is still loading. Try again in a moment.',
copyFailed: 'Clipboard write was blocked. Try again in a moment.',
promptIntro: 'Continue from this local Open Design project folder:',
target: 'Target',
stepsLead: 'You are taking over in {cli}. Please:',
readFiles: 'Enter or read this directory first. Prioritize DESIGN.md, README, existing HTML/CSS/JS, assets, and package.json if present.',
keepDesign: 'Preserve the current visual design, layout, interactions, and assets. Do not stop at a plan.',
produceCode: 'Generate or modify real runnable {framework} code. If the project already has a clearer stack, call out the conflict and keep the result runnable.',
verify: 'Finish by telling me the run, preview, and verification commands.',
commandHint: 'To start from the project directory, use:',
project: 'Project',
};
}
function interpolate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? `{${key}}`);
}
function buildCliHandoffPrompt({
cli,
framework,
labels,
projectDir,
projectId,
projectName,
}: {
cli: CliTarget;
framework: FrameworkTarget;
labels: ReturnType<typeof uiCopy>;
projectDir: string;
projectId: string;
projectName?: string;
}): string {
const name = projectName?.trim() || projectId;
return `${labels.promptIntro}
\`\`\`
${projectDir}
\`\`\`
${labels.target}: ${framework.promptLabel}
CLI: ${cliDisplayName(cli)}${cli.bin ? ` (${cli.bin})` : ''}
${interpolate(labels.stepsLead, { cli: cliDisplayName(cli) })}
1. ${labels.readFiles}
2. ${labels.keepDesign}
3. ${interpolate(labels.produceCode, { framework: framework.promptLabel })}
4. ${labels.verify}
${labels.commandHint}
\`\`\`bash
cd ${shellQuote(projectDir)}
\`\`\`
${labels.project}: ${name}
Project ID: ${projectId}
`;
}
export function HandoffButton({
projectId,
projectName,
projectDir,
agents,
onRequestRevealInFinder,
}: Props) {
const { locale, t } = useI18n();
const labels = uiCopy(locale);
const [editors, setEditors] = useState<HostEditor[]>([]); const [editors, setEditors] = useState<HostEditor[]>([]);
const [platform, setPlatform] = useState<HostEditorsResponse['platform']>('unknown'); const [platform, setPlatform] = useState<HostEditorsResponse['platform']>('unknown');
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [busy, setBusy] = useState<HostEditorId | null>(null); const [busy, setBusy] = useState<HostEditorId | null>(null);
const [copyBusy, setCopyBusy] = useState<string | null>(null);
const [copiedCliId, setCopiedCliId] = useState<string | null>(null);
const [frameworkId, setFrameworkId] = useState(readPreferredFramework);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null); const wrapRef = useRef<HTMLDivElement | null>(null);
const copiedTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -88,6 +344,14 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
}; };
}, [open]); }, [open]);
useEffect(() => {
return () => {
if (copiedTimerRef.current !== null) {
window.clearTimeout(copiedTimerRef.current);
}
};
}, []);
const available = editors.filter((e) => e.available); const available = editors.filter((e) => e.available);
const unavailable = editors.filter((e) => !e.available); const unavailable = editors.filter((e) => !e.available);
const preferred = readPreferred(); const preferred = readPreferred();
@ -96,6 +360,10 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
const primaryTitle = primary const primaryTitle = primary
? t('handoff.openInTarget', { target: primary.label }) ? t('handoff.openInTarget', { target: primary.label })
: t('handoff.action'); : t('handoff.action');
const editorTargets = [...available, ...unavailable];
const cliTargets = useMemo(() => mergeCliTargets(agents), [agents]);
const selectedFramework =
FRAMEWORKS.find((framework) => framework.id === frameworkId) ?? DEFAULT_FRAMEWORK;
async function launch(editor: HostEditor) { async function launch(editor: HostEditor) {
if (!editor.available) { if (!editor.available) {
@ -126,6 +394,40 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
} }
} }
async function copyCliPrompt(cli: CliTarget) {
if (!projectDir) {
setError(labels.unavailablePath);
return;
}
setError(null);
setCopyBusy(cli.id);
const prompt = buildCliHandoffPrompt({
cli,
framework: selectedFramework,
labels,
projectDir,
projectId,
projectName,
});
try {
const copied = await copyToClipboard(prompt);
if (!copied) {
setError(labels.copyFailed);
return;
}
setCopiedCliId(cli.id);
if (copiedTimerRef.current !== null) {
window.clearTimeout(copiedTimerRef.current);
}
copiedTimerRef.current = window.setTimeout(() => {
setCopiedCliId(null);
copiedTimerRef.current = null;
}, 1800);
} finally {
setCopyBusy(null);
}
}
if (!loaded) { if (!loaded) {
return null; return null;
} }
@ -229,45 +531,90 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
</div> </div>
{open ? ( {open ? (
<div className="handoff-menu" role="menu" data-testid="handoff-menu"> <div className="handoff-menu" role="menu" data-testid="handoff-menu">
<div className="handoff-menu-title">{t('handoff.menuTitle')}</div> <section className="handoff-menu-block">
{available.map((editor) => ( <div className="handoff-menu-title">{labels.editorSection}</div>
<button <div className="handoff-target-rail handoff-editor-rail">
key={editor.id} {editorTargets.map((editor) => (
type="button"
className={`handoff-menu-item${editor.id === preferred ? ' active' : ''}`}
role="menuitem"
data-testid={`handoff-menu-item-${editor.id}`}
onClick={() => void launch(editor)}
disabled={busy === editor.id}
>
<EditorIcon editorId={editor.id} size={20} />
<span>{editor.label}</span>
{editor.id === preferred ? (
<Icon name="check" size={12} />
) : null}
</button>
))}
{unavailable.length > 0 ? (
<>
<div className="handoff-menu-divider" />
<div className="handoff-menu-section">{t('handoff.notInstalled')}</div>
{unavailable.map((editor) => (
<button <button
key={editor.id} key={editor.id}
type="button" type="button"
className="handoff-menu-item dim" className={[
'handoff-menu-item',
'handoff-target-card',
editor.id === preferred ? 'active' : '',
editor.available ? '' : 'dim',
].filter(Boolean).join(' ')}
role="menuitem" role="menuitem"
data-testid={`handoff-menu-item-${editor.id}`} data-testid={`handoff-menu-item-${editor.id}`}
onClick={() => void launch(editor)} onClick={() => void launch(editor)}
disabled={busy === editor.id} disabled={busy === editor.id}
title={t('handoff.notDetectedTitle', { target: editor.label })} title={
editor.available
? t('handoff.openInTarget', { target: editor.label })
: t('handoff.notDetectedTitle', { target: editor.label })
}
> >
<EditorIcon editorId={editor.id} size={20} /> <EditorIcon editorId={editor.id} size={24} />
<span>{editor.label}</span> <span className="handoff-target-label">{editor.label}</span>
{!editor.available ? (
<span className="handoff-target-meta">{t('handoff.notInstalled')}</span>
) : null}
{editor.id === preferred ? (
<Icon name="check" size={12} />
) : null}
</button> </button>
))} ))}
</> </div>
) : null} </section>
<section className="handoff-menu-block">
<div className="handoff-menu-title">{labels.cliSection}</div>
<div className="handoff-framework-row" role="group" aria-label={labels.framework}>
<span className="handoff-framework-label">{labels.framework}</span>
{FRAMEWORKS.map((framework) => (
<button
key={framework.id}
type="button"
className={`handoff-framework-chip${framework.id === selectedFramework.id ? ' active' : ''}`}
aria-pressed={framework.id === selectedFramework.id}
onClick={() => {
setFrameworkId(framework.id);
writePreferredFramework(framework.id);
}}
>
{framework.label}
</button>
))}
</div>
<div className="handoff-target-rail handoff-cli-rail">
{cliTargets.map((cli) => {
const copied = copiedCliId === cli.id;
return (
<button
key={cli.id}
type="button"
className={[
'handoff-menu-item',
'handoff-target-card',
'handoff-cli-card',
cli.available ? '' : 'dim',
copied ? 'copied' : '',
].filter(Boolean).join(' ')}
role="menuitem"
data-testid={`handoff-cli-item-${cli.id}`}
onClick={() => void copyCliPrompt(cli)}
disabled={copyBusy === cli.id}
title={`${labels.copyPrompt}: ${cliDisplayName(cli)}`}
>
<AgentIcon id={cli.id} size={24} />
<span className="handoff-target-label">{cliDisplayName(cli)}</span>
<span className="handoff-target-meta">
{copied ? labels.copied : cli.available ? labels.copyPrompt : labels.notInstalled}
</span>
</button>
);
})}
</div>
</section>
{error ? ( {error ? (
<> <>
<div className="handoff-menu-divider" /> <div className="handoff-menu-divider" />

View file

@ -4297,7 +4297,12 @@ export function ProjectView({
> >
<Icon name="sliders" size={16} /> <Icon name="sliders" size={16} />
</button> </button>
<HandoffButton projectId={project.id} /> <HandoffButton
projectId={project.id}
projectName={project.name}
projectDir={projectDetail.resolvedDir}
agents={agents}
/>
<AvatarMenu <AvatarMenu
config={config} config={config}
agents={agents} agents={agents}

View file

@ -1610,23 +1610,32 @@
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; right: 0;
min-width: 200px; width: min(520px, calc(100vw - 24px));
max-height: min(72vh, 620px);
overflow: auto;
background: var(--bg-panel); background: var(--bg-panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.12)); box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.12));
padding: 6px; padding: 8px;
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px;
}
.app .handoff-menu-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
} }
.app .handoff-menu-title { .app .handoff-menu-title {
padding: 6px 10px 7px; padding: 0 2px;
border-bottom: 1px solid var(--border); margin: 0;
margin: 0 0 4px; color: var(--text);
color: var(--text-muted);
font-size: 11.5px; font-size: 11.5px;
line-height: 16px; line-height: 16px;
font-weight: 650;
} }
.app .handoff-menu-item { .app .handoff-menu-item {
display: inline-flex; display: inline-flex;
@ -1649,12 +1658,113 @@
background: color-mix(in srgb, var(--accent) 10%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent);
} }
.app .handoff-menu-item.dim { .app .handoff-menu-item.dim {
opacity: 0.55; opacity: 0.62;
}
.app .handoff-target-rail {
display: grid;
grid-auto-flow: column;
gap: 6px;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 1px 1px 4px;
scrollbar-width: thin;
}
.app .handoff-editor-rail {
grid-template-rows: minmax(66px, auto);
grid-auto-columns: 88px;
}
.app .handoff-cli-rail {
grid-template-rows: repeat(2, minmax(72px, auto));
grid-auto-columns: 96px;
}
.app .handoff-target-card {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 5px;
width: auto;
min-width: 0;
min-height: 66px;
padding: 8px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-panel) 92%, var(--text) 4%);
}
.app .handoff-target-card:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
}
.app .handoff-target-card.active,
.app .handoff-target-card.copied {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel));
}
.app .handoff-target-card.active > svg {
position: absolute;
top: 6px;
right: 6px;
color: var(--accent);
}
.app .handoff-target-card .agent-icon {
color: var(--text);
}
.app .handoff-target-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
line-height: 14px;
font-weight: 600;
}
.app .handoff-target-meta {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 10.5px;
line-height: 12px;
}
.app .handoff-framework-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
padding: 3px 0 1px;
}
.app .handoff-framework-label {
color: var(--text-muted);
font-size: 10.5px;
line-height: 18px;
margin-right: 2px;
}
.app .handoff-framework-chip {
height: 22px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.app .handoff-framework-chip:hover {
color: var(--text);
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, transparent);
}
.app .handoff-framework-chip.active {
color: var(--text);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 11%, transparent);
} }
.app .handoff-menu-divider { .app .handoff-menu-divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
margin: 4px 0; margin: 0;
} }
.app .handoff-menu-section { .app .handoff-menu-section {
font-size: 10.5px; font-size: 10.5px;

View file

@ -239,11 +239,15 @@
/* Design browser workspace module. */ /* Design browser workspace module. */
.design-browser { .design-browser {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%;
height: 100%;
max-width: 100%;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
overflow: hidden;
} }
.db-chrome { .db-chrome {
position: relative; position: relative;
@ -251,9 +255,11 @@
grid-template-columns: auto minmax(220px, 1fr) auto; grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px; min-width: 0;
padding: 7px 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-panel); background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 96%, #fff) 0%, var(--bg-panel) 100%);
z-index: 3; z-index: 3;
} }
.db-nav, .db-nav,
@ -298,11 +304,11 @@
transform: translate(-50%, 0); transform: translate(-50%, 0);
} }
.db-icon-btn { .db-icon-btn {
width: 30px; width: 32px;
height: 30px; height: 32px;
padding: 0; padding: 0;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: 7px;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
display: inline-flex; display: inline-flex;
@ -312,7 +318,7 @@
transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1), color 120ms cubic-bezier(0.23, 1, 0.32, 1); transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1), color 120ms cubic-bezier(0.23, 1, 0.32, 1);
} }
.db-icon-btn:hover:not(:disabled) { .db-icon-btn:hover:not(:disabled) {
background: var(--bg-subtle); background: color-mix(in srgb, var(--bg-subtle) 78%, var(--text) 4%);
color: var(--text); color: var(--text);
} }
.db-icon-btn:disabled { .db-icon-btn:disabled {
@ -331,20 +337,48 @@
} }
.db-address-form { .db-address-form {
position: relative; position: relative;
height: 34px; height: 36px;
min-width: 0; min-width: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 10px;
background: var(--bg); background: color-mix(in srgb, var(--bg) 88%, var(--bg-panel));
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 9px;
padding: 0 10px; padding: 0 11px;
overflow: visible;
transition: background 140ms cubic-bezier(0.23, 1, 0.32, 1), border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.db-address-form:hover {
border-color: color-mix(in srgb, var(--border) 70%, var(--text-muted));
background: var(--bg);
} }
.db-address-form:focus-within { .db-address-form:focus-within {
border-color: var(--accent); border-color: color-mix(in srgb, var(--border) 55%, var(--text-muted));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: none;
background: var(--bg);
}
.db-site-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
overflow: hidden;
}
.db-site-icon img {
width: 100%;
height: 100%;
display: block;
border-radius: 3px;
object-fit: contain;
}
.db-address-site-icon {
width: 17px;
height: 17px;
} }
.db-address-form input { .db-address-form input {
flex: 1 1 auto; flex: 1 1 auto;
@ -365,11 +399,11 @@
top: calc(100% + 8px); top: calc(100% + 8px);
left: 0; left: 0;
right: 0; right: 0;
max-height: min(420px, calc(100vh - 160px)); max-height: min(520px, calc(100vh - 136px));
padding: 6px; padding: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 10px;
background: var(--bg-panel); background: color-mix(in srgb, var(--bg-panel) 96%, var(--bg));
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
overflow-y: auto; overflow-y: auto;
z-index: 220; z-index: 220;
@ -382,7 +416,7 @@
align-items: center; align-items: center;
gap: 9px; gap: 9px;
border: 0; border: 0;
border-radius: var(--radius-sm); border-radius: 8px;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
padding: 7px 8px; padding: 7px 8px;
@ -390,12 +424,12 @@
text-align: left; text-align: left;
} }
.db-suggestions button:hover { .db-suggestions button:hover {
background: var(--bg-subtle); background: color-mix(in srgb, var(--bg-subtle) 78%, var(--text) 4%);
} }
.db-suggestion-icon { .db-suggestion-icon {
width: 24px; width: 26px;
height: 24px; height: 26px;
border-radius: var(--radius-sm); border-radius: 7px;
background: var(--bg-subtle); background: var(--bg-subtle);
color: var(--text-muted); color: var(--text-muted);
display: inline-flex; display: inline-flex;
@ -478,9 +512,11 @@
.db-content { .db-content {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
min-width: 0;
position: relative; position: relative;
display: flex; display: flex;
background: var(--bg); background: var(--bg);
overflow: hidden;
} }
.db-webview, .db-webview,
.db-fallback, .db-fallback,
@ -500,25 +536,6 @@
.db-fallback iframe { .db-fallback iframe {
background: #fff; background: #fff;
} }
.db-fallback-bar {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
color: var(--text-muted);
font-size: 12px;
box-shadow: var(--shadow-lg);
}
.db-fallback-bar button,
.db-reference-actions button { .db-reference-actions button {
min-height: 28px; min-height: 28px;
display: inline-flex; display: inline-flex;
@ -532,7 +549,6 @@
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
} }
.db-fallback-bar button:hover,
.db-reference-actions button:hover:not(:disabled) { .db-reference-actions button:hover:not(:disabled) {
background: var(--bg-subtle); background: var(--bg-subtle);
} }
@ -540,16 +556,18 @@
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow-y: auto; max-width: 100%;
padding: 28px; overflow: auto;
background: var(--bg); padding: 18px;
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 30%, var(--bg)) 0%, var(--bg) 34%);
overscroll-behavior: contain;
} }
.db-start-hero { .db-start-hero {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); grid-template-columns: minmax(0, 1fr) auto;
gap: 24px; gap: 14px;
align-items: end; align-items: center;
padding: 10px 0 24px; padding: 0 0 14px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.db-kicker { .db-kicker {
@ -560,17 +578,17 @@
} }
.db-start h2 { .db-start h2 {
max-width: 720px; max-width: 720px;
margin: 8px 0 0; margin: 5px 0 0;
color: var(--text); color: var(--text);
font-size: 46px; font-size: 28px;
line-height: 0.96; line-height: 1.02;
letter-spacing: 0; letter-spacing: 0;
} }
.db-agent-card { .db-agent-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--bg-panel); background: color-mix(in srgb, var(--bg-panel) 92%, var(--bg-subtle));
padding: 14px; padding: 9px 11px;
} }
.db-agent-card-title { .db-agent-card-title {
display: flex; display: flex;
@ -589,14 +607,14 @@
.db-reference-grid { .db-reference-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; gap: 12px;
padding-top: 22px; padding-top: 14px;
} }
.db-reference-group { .db-reference-group {
min-width: 0; min-width: 0;
} }
.db-reference-group h3 { .db-reference-group h3 {
margin: 0 0 10px; margin: 0 0 8px;
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@ -606,21 +624,26 @@
.db-reference-list { .db-reference-list {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 10px; gap: 8px;
} }
.db-reference-card { .db-reference-card {
min-width: 0; min-width: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: color-mix(in srgb, var(--bg-panel) 94%, var(--bg));
padding: 9px;
transition: border-color 140ms cubic-bezier(0.23, 1, 0.32, 1), background 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.db-reference-card:hover {
border-color: color-mix(in srgb, var(--border) 58%, var(--text-muted));
background: var(--bg-panel); background: var(--bg-panel);
padding: 12px;
} }
.db-reference-card > button { .db-reference-card > button {
width: 100%; width: 100%;
display: flex; display: grid;
align-items: baseline; grid-template-columns: 24px minmax(0, 1fr);
justify-content: space-between; align-items: center;
gap: 10px; gap: 8px;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
@ -628,27 +651,42 @@
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
.db-reference-card > button span, .db-reference-icon {
.db-reference-card > button small { width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
padding: 4px;
}
.db-reference-title {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.db-reference-title span,
.db-reference-title small {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.db-reference-card > button span { .db-reference-title span {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
.db-reference-card > button small { .db-reference-title small {
color: var(--text-faint); color: var(--text-faint);
font-size: 11px; font-size: 11px;
} }
.db-reference-card p { .db-reference-card p {
margin: 8px 0 12px; margin: 7px 0 9px;
min-height: 36px; min-height: 34px;
color: var(--text-muted); color: var(--text-muted);
font-size: 12.5px; font-size: 12px;
line-height: 1.45; line-height: 1.38;
} }
.db-reference-actions { .db-reference-actions {
display: flex; display: flex;

View file

@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
browserFileName, browserFileName,
browserHarnessTaskMarkdown, browserHarnessTaskMarkdown,
faviconUrl,
formatAddressDisplay,
hostnameFromUrl,
isHistoryEntry, isHistoryEntry,
isHistoryUrl, isHistoryUrl,
labelFromUrl, labelFromUrl,
@ -105,6 +108,42 @@ describe('labelFromUrl', () => {
}); });
}); });
describe('formatAddressDisplay', () => {
it('keeps the URL alone when the title is only the host fallback', () => {
expect(formatAddressDisplay('https://www.example.com/path', 'example.com')).toBe('https://www.example.com/path');
});
it('appends a real page title for the passive address display', () => {
expect(formatAddressDisplay('https://www.baidu.com/', '百度一下,你就知道')).toBe(
'https://www.baidu.com/ / 百度一下,你就知道',
);
});
it('keeps the blank tab display empty', () => {
expect(formatAddressDisplay('about:blank', 'New Tab')).toBe('');
});
});
describe('hostnameFromUrl', () => {
it('returns a compact hostname without www', () => {
expect(hostnameFromUrl('https://www.example.com/docs')).toBe('example.com');
});
it('falls back to the raw value when parsing fails', () => {
expect(hostnameFromUrl('not a url')).toBe('not a url');
});
});
describe('faviconUrl', () => {
it('derives a same-origin favicon URL for http pages', () => {
expect(faviconUrl('https://www.example.com/docs')).toBe('https://www.example.com/favicon.ico');
});
it('skips non-http urls', () => {
expect(faviconUrl('file:///Users/me/page.html')).toBeUndefined();
});
});
describe('isHistoryUrl', () => { describe('isHistoryUrl', () => {
it('accepts http(s) and file URLs', () => { it('accepts http(s) and file URLs', () => {
expect(isHistoryUrl('https://example.com')).toBe(true); expect(isHistoryUrl('https://example.com')).toBe(true);
@ -189,7 +228,7 @@ describe('browserFileName', () => {
describe('isHistoryEntry', () => { describe('isHistoryEntry', () => {
it('accepts a well-formed entry', () => { it('accepts a well-formed entry', () => {
expect( expect(
isHistoryEntry({ url: 'https://x', title: 'X', lastVisitedAt: 1, visitCount: 1 }), isHistoryEntry({ url: 'https://x', title: 'X', iconUrl: 'https://x/favicon.ico', lastVisitedAt: 1, visitCount: 1 }),
).toBe(true); ).toBe(true);
}); });

View file

@ -48,6 +48,14 @@ function dispatchWebviewNavigate(webview: HTMLElement, url: string) {
}); });
} }
function dispatchWebviewTitle(webview: HTMLElement, title: string) {
act(() => {
const event = new Event('page-title-updated') as Event & { title?: string };
event.title = title;
webview.dispatchEvent(event);
});
}
describe('DesignBrowserPanel <webview> navigation', () => { describe('DesignBrowserPanel <webview> navigation', () => {
it('pins the webview src to the load target when the guest commits a redirected URL', () => { it('pins the webview src to the load target when the guest commits a redirected URL', () => {
// Regression guard for the blank-page bug: the embedded <webview> rendered // Regression guard for the blank-page bug: the embedded <webview> rendered
@ -133,4 +141,86 @@ describe('DesignBrowserPanel <webview> navigation', () => {
expect(loadURL).toHaveBeenCalledWith('https://example.com/'); expect(loadURL).toHaveBeenCalledWith('https://example.com/');
expect(forwardButton.disabled).toBe(false); expect(forwardButton.disabled).toBe(false);
}); });
it('uses native webview history for back navigation when Chromium has it cached', () => {
const { container } = render(
<DesignBrowserPanel projectId="proj-webview-native" 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 & {
canGoBack?: () => boolean;
goBack?: () => void;
loadURL?: (url: string) => void;
};
dispatchWebviewNavigate(webview, 'https://example.com/');
dispatchWebviewNavigate(webview, 'https://example.com/docs/');
const goBack = vi.fn();
const loadURL = vi.fn();
webview.canGoBack = () => true;
webview.goBack = goBack;
webview.loadURL = loadURL;
fireEvent.click(screen.getByRole('button', { name: 'Go Back' }));
expect(goBack).toHaveBeenCalledTimes(1);
expect(loadURL).not.toHaveBeenCalled();
});
it('shows extracted page titles in the passive address display and history suggestions', () => {
const { container } = render(
<DesignBrowserPanel projectId="proj-webview-title" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'https://www.baidu.com' } });
fireEvent.submit(input.closest('form')!);
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
getTitle?: () => string;
getURL?: () => string;
};
webview.getURL = () => 'https://www.baidu.com/';
webview.getTitle = () => '百度一下,你就知道';
dispatchWebviewNavigate(webview, 'https://www.baidu.com/');
dispatchWebviewTitle(webview, '百度一下,你就知道');
fireEvent.blur(input);
expect(input.value).toBe('https://www.baidu.com/ / 百度一下,你就知道');
fireEvent.focus(input);
expect(input.value).toBe('https://www.baidu.com/');
expect(screen.getByRole('option', { name: /百度一下,你就知道/ })).toBeTruthy();
});
it('opens all reference suggestions by default from the address bar', () => {
render(
<DesignBrowserPanel projectId="proj-webview-suggestions" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
fireEvent.focus(screen.getByLabelText('Browser address'));
expect(screen.getByRole('option', { name: /Whirrls/ })).toBeTruthy();
expect(screen.getByRole('option', { name: /Startups Gallery/ })).toBeTruthy();
});
it('keeps the browser fallback content free of desktop-only overlay banners', () => {
restoreHost?.();
restoreHost = null;
const { container } = render(
<DesignBrowserPanel projectId="proj-browser-fallback" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'https://example.com' } });
fireEvent.submit(input.closest('form')!);
expect(container.querySelector('iframe')).not.toBeNull();
expect(screen.queryByText('Embedded browser controls are available in the desktop app.')).toBeNull();
});
}); });

View file

@ -10,20 +10,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { HandoffButton } from '../../src/components/HandoffButton'; import { HandoffButton } from '../../src/components/HandoffButton';
import { I18nProvider } from '../../src/i18n'; import { I18nProvider } from '../../src/i18n';
import type { HostEditorsResponse } from '@open-design/contracts'; import type { AgentInfo, HostEditorsResponse } from '@open-design/contracts';
const fetchHostEditors = vi.fn<() => Promise<HostEditorsResponse>>(); const fetchHostEditors = vi.fn<() => Promise<HostEditorsResponse>>();
const openProjectInEditor = vi.fn(); const openProjectInEditor = vi.fn();
const copyToClipboard = vi.fn();
vi.mock('../../src/providers/registry', () => ({ vi.mock('../../src/providers/registry', () => ({
fetchHostEditors: () => fetchHostEditors(), fetchHostEditors: () => fetchHostEditors(),
openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args), openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args),
})); }));
vi.mock('../../src/lib/copy-to-clipboard', () => ({
copyToClipboard: (...args: unknown[]) => copyToClipboard(...args),
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
fetchHostEditors.mockReset(); fetchHostEditors.mockReset();
openProjectInEditor.mockReset(); openProjectInEditor.mockReset();
copyToClipboard.mockReset();
}); });
describe('HandoffButton zero-editors fallback', () => { describe('HandoffButton zero-editors fallback', () => {
@ -69,4 +75,54 @@ describe('HandoffButton zero-editors fallback', () => {
const errorEl = await screen.findByTestId('handoff-fallback-error'); const errorEl = await screen.findByTestId('handoff-fallback-error');
expect(errorEl.textContent).toContain('daemon refused: ENOENT'); expect(errorEl.textContent).toContain('daemon refused: ENOENT');
}); });
it('copies a framework-specific CLI handoff prompt with the local project path', async () => {
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [
{
id: 'cursor',
label: 'Cursor',
available: true,
},
],
});
copyToClipboard.mockResolvedValue(true);
const agents: AgentInfo[] = [
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
},
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: false,
},
];
render(
<I18nProvider initial="zh-CN">
<HandoffButton
projectId="p1"
projectName="Landing"
projectDir="/tmp/open-design/Landing"
agents={agents}
/>
</I18nProvider>,
);
fireEvent.click(await screen.findByTestId('handoff-caret'));
fireEvent.click(await screen.findByRole('button', { name: 'Vue.js' }));
fireEvent.click(await screen.findByTestId('handoff-cli-item-claude'));
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledTimes(1));
const prompt = copyToClipboard.mock.calls[0]?.[0] as string;
expect(prompt).toContain('/tmp/open-design/Landing');
expect(prompt).toContain('Vue.js');
expect(prompt).toContain('Claude Code');
expect(prompt).toContain('真实可运行');
});
}); });