mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
bc336bf14b
commit
1109bb15da
8 changed files with 1034 additions and 171 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { Icon } from './Icon';
|
||||
|
||||
type BrowserHistoryEntry = {
|
||||
iconUrl?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
lastVisitedAt: number;
|
||||
|
|
@ -77,6 +78,10 @@ type WebviewTitleEvent = Event & {
|
|||
title?: string;
|
||||
};
|
||||
|
||||
type WebviewFaviconEvent = Event & {
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
interface DesignBrowserPanelProps {
|
||||
projectId: string;
|
||||
onOpenFile: (name: string) => void;
|
||||
|
|
@ -86,6 +91,8 @@ interface DesignBrowserPanelProps {
|
|||
const EMPTY_URL = 'about:blank';
|
||||
const DESIGN_BROWSER_PARTITION = 'persist:open-design-design-browser';
|
||||
const HISTORY_LIMIT = 80;
|
||||
const HISTORY_SUGGESTION_LIMIT = 20;
|
||||
const warmedOrigins = new Set<string>();
|
||||
|
||||
const REFERENCE_GROUPS: ReferenceGroup[] = [
|
||||
{
|
||||
|
|
@ -175,6 +182,7 @@ export function DesignBrowserPanel({
|
|||
const [loadUrl, setLoadUrl] = useState(EMPTY_URL);
|
||||
const [currentUrl, setCurrentUrl] = useState(EMPTY_URL);
|
||||
const [addressValue, setAddressValue] = useState('');
|
||||
const [addressEditing, setAddressEditing] = useState(false);
|
||||
const [history, setHistory] = useState<BrowserHistoryEntry[]>(() => loadHistory(projectId));
|
||||
const [navigationStack, setNavigationStack] = useState<BrowserNavigationEntry[]>([]);
|
||||
const [navigationIndex, setNavigationIndex] = useState(-1);
|
||||
|
|
@ -184,6 +192,7 @@ export function DesignBrowserPanel({
|
|||
const [webviewNode, setWebviewNode] = useState<WebviewElement | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [savingAction, setSavingAction] = useState<'brief' | 'screenshot' | 'task' | null>(null);
|
||||
const addressInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const chromeRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigationStackRef = useRef<BrowserNavigationEntry[]>([]);
|
||||
const navigationIndexRef = useRef(-1);
|
||||
|
|
@ -206,6 +215,7 @@ export function DesignBrowserPanel({
|
|||
setLoadUrl(EMPTY_URL);
|
||||
setCurrentUrl(EMPTY_URL);
|
||||
setAddressValue('');
|
||||
setAddressEditing(false);
|
||||
setNavigationStack([]);
|
||||
setNavigationIndex(-1);
|
||||
navigationStackRef.current = [];
|
||||
|
|
@ -214,7 +224,8 @@ export function DesignBrowserPanel({
|
|||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
saveHistory(projectId, history);
|
||||
const timer = window.setTimeout(() => saveHistory(projectId, history), 140);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [history, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -235,15 +246,34 @@ export function DesignBrowserPanel({
|
|||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [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;
|
||||
setHistory((current) => {
|
||||
const now = Date.now();
|
||||
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
|
||||
? { ...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))]
|
||||
.slice(0, HISTORY_LIMIT);
|
||||
});
|
||||
|
|
@ -320,21 +350,42 @@ export function DesignBrowserPanel({
|
|||
setNavigationState(nextStack, index);
|
||||
}, [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 nextUrl = normalizeBrowserAddress(rawAddress);
|
||||
warmBrowserOrigin(nextUrl);
|
||||
pendingLoadTargetRef.current = isHistoryUrl(nextUrl) ? nextUrl : null;
|
||||
setLoadUrl(nextUrl);
|
||||
setCurrentUrl(nextUrl);
|
||||
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
|
||||
setAddressEditing(false);
|
||||
setSuggestionsOpen(false);
|
||||
setMenuOpen(false);
|
||||
if (isHistoryUrl(nextUrl)) {
|
||||
commitHistory(nextUrl);
|
||||
commitHistory(nextUrl, undefined, { countVisit: true });
|
||||
recordNavigation(nextUrl);
|
||||
} else if (nextUrl === EMPTY_URL) {
|
||||
setLoadUrl(EMPTY_URL);
|
||||
recordNavigation(nextUrl);
|
||||
}
|
||||
}, [commitHistory, recordNavigation]);
|
||||
if (nextUrl !== EMPTY_URL) loadWebviewUrl(nextUrl);
|
||||
}, [commitHistory, loadWebviewUrl, recordNavigation]);
|
||||
|
||||
const updateLoadingState = useCallback((node: WebviewElement | null = webviewNode) => {
|
||||
if (!node) {
|
||||
|
|
@ -356,15 +407,21 @@ export function DesignBrowserPanel({
|
|||
const node = webviewNode;
|
||||
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);
|
||||
if (nextUrl) {
|
||||
setCurrentUrl(nextUrl);
|
||||
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
|
||||
if (!addressEditing) {
|
||||
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
|
||||
}
|
||||
}
|
||||
const nextTitle = title || safeGetWebviewTitle(node);
|
||||
if (nextUrl) {
|
||||
commitHistory(nextUrl, nextTitle);
|
||||
commitHistory(nextUrl, { iconUrl: options?.iconUrl, title: nextTitle }, { countVisit: options?.recordVisit === true });
|
||||
if (options?.recordNavigation !== false) {
|
||||
recordNavigation(nextUrl, nextTitle, { replacePendingTarget: true });
|
||||
} else {
|
||||
|
|
@ -379,16 +436,25 @@ export function DesignBrowserPanel({
|
|||
};
|
||||
const onStop = () => {
|
||||
setIsLoading(false);
|
||||
syncFromWebview();
|
||||
syncFromWebview(undefined, undefined, { recordVisit: false });
|
||||
};
|
||||
const onNavigate = (event: Event) => {
|
||||
const navigationEvent = event as WebviewNavigationEvent;
|
||||
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 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 navigationEvent = event as WebviewNavigationEvent;
|
||||
|
|
@ -403,6 +469,7 @@ export function DesignBrowserPanel({
|
|||
node.addEventListener('did-navigate', onNavigate);
|
||||
node.addEventListener('did-navigate-in-page', onNavigate);
|
||||
node.addEventListener('page-title-updated', onTitle);
|
||||
node.addEventListener('page-favicon-updated', onFavicon);
|
||||
node.addEventListener('did-fail-load', onFail);
|
||||
node.addEventListener('dom-ready', onStop);
|
||||
updateLoadingState(node);
|
||||
|
|
@ -412,39 +479,46 @@ export function DesignBrowserPanel({
|
|||
node.removeEventListener('did-navigate', onNavigate);
|
||||
node.removeEventListener('did-navigate-in-page', onNavigate);
|
||||
node.removeEventListener('page-title-updated', onTitle);
|
||||
node.removeEventListener('page-favicon-updated', onFavicon);
|
||||
node.removeEventListener('did-fail-load', onFail);
|
||||
node.removeEventListener('dom-ready', onStop);
|
||||
};
|
||||
}, [commitHistory, recordNavigation, updateCurrentNavigationTitle, updateLoadingState, webviewNode]);
|
||||
}, [addressEditing, commitHistory, recordNavigation, updateCurrentNavigationTitle, updateLoadingState, webviewNode]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const query = addressValue.trim().toLocaleLowerCase();
|
||||
const showDefaultSuggestions = addressEditing && currentUrl !== EMPTY_URL && sameUrl(addressValue.trim(), currentUrl);
|
||||
const referenceSuggestions = REFERENCE_GROUPS.flatMap((group) =>
|
||||
group.sites.map((site) => ({
|
||||
detail: `${group.title} - ${site.detail}`,
|
||||
id: `site:${site.url}`,
|
||||
iconUrl: faviconUrl(site.url),
|
||||
label: site.label,
|
||||
type: 'Reference' as const,
|
||||
url: site.url,
|
||||
})),
|
||||
);
|
||||
const historySuggestions = history.map((entry) => ({
|
||||
const historySuggestions = history.slice(0, HISTORY_SUGGESTION_LIMIT).map((entry) => ({
|
||||
detail: entry.url,
|
||||
id: `history:${entry.url}`,
|
||||
iconUrl: entry.iconUrl || faviconUrl(entry.url),
|
||||
label: entry.title || labelFromUrl(entry.url),
|
||||
type: 'History' as const,
|
||||
url: entry.url,
|
||||
}));
|
||||
const all = [...historySuggestions, ...referenceSuggestions];
|
||||
if (!query) return all.slice(0, 12);
|
||||
if (!query || showDefaultSuggestions) return all;
|
||||
return all
|
||||
.filter((item) =>
|
||||
`${item.label} ${item.url} ${item.detail}`.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
.slice(0, 12);
|
||||
}, [addressValue, history]);
|
||||
.slice(0, HISTORY_SUGGESTION_LIMIT + referenceSuggestions.length);
|
||||
}, [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
|
||||
// URL, so a transient about:blank navigation event can't unmount the webview.
|
||||
const isBlank = loadUrl === EMPTY_URL;
|
||||
|
|
@ -452,6 +526,7 @@ export function DesignBrowserPanel({
|
|||
async function handleAddressSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
navigateTo(addressValue);
|
||||
addressInputRef.current?.blur();
|
||||
}
|
||||
|
||||
async function copyCurrentUrl() {
|
||||
|
|
@ -560,32 +635,21 @@ export function DesignBrowserPanel({
|
|||
setLoadUrl(EMPTY_URL);
|
||||
setCurrentUrl(EMPTY_URL);
|
||||
setAddressValue('');
|
||||
setAddressEditing(false);
|
||||
setNavigationState([], -1);
|
||||
pendingLoadTargetRef.current = null;
|
||||
saveHistory(projectId, []);
|
||||
}
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function clearHistoryOnly() {
|
||||
setHistory([]);
|
||||
saveHistory(projectId, []);
|
||||
setStatusMessage('History cleared');
|
||||
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];
|
||||
|
|
@ -594,9 +658,15 @@ export function DesignBrowserPanel({
|
|||
setNavigationState(navigationStack.slice(), targetIndex);
|
||||
setCurrentUrl(entry.url);
|
||||
setAddressValue(entry.url);
|
||||
setAddressEditing(false);
|
||||
setSuggestionsOpen(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) {
|
||||
|
|
@ -645,14 +715,30 @@ export function DesignBrowserPanel({
|
|||
</IconTooltipButton>
|
||||
</div>
|
||||
<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
|
||||
value={addressValue}
|
||||
ref={addressInputRef}
|
||||
value={shownAddressValue}
|
||||
onChange={(event) => {
|
||||
setAddressEditing(true);
|
||||
setAddressValue(event.target.value);
|
||||
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..."
|
||||
aria-label="Browser address"
|
||||
autoComplete="off"
|
||||
|
|
@ -665,10 +751,15 @@ export function DesignBrowserPanel({
|
|||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
onFocus={() => warmBrowserOrigin(item.url)}
|
||||
onPointerEnter={() => warmBrowserOrigin(item.url)}
|
||||
onClick={() => navigateTo(item.url)}
|
||||
>
|
||||
<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 className="db-suggestion-copy">
|
||||
<span>{item.label}</span>
|
||||
|
|
@ -757,13 +848,6 @@ export function DesignBrowserPanel({
|
|||
) : (
|
||||
<div className="db-fallback">
|
||||
<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>
|
||||
|
|
@ -808,14 +892,13 @@ function DesignBrowserStart({
|
|||
<div className="db-start-hero">
|
||||
<div>
|
||||
<div className="db-kicker">Open Design browser</div>
|
||||
<h2>Reference, extract, apply.</h2>
|
||||
<h2>Reference Board</h2>
|
||||
</div>
|
||||
<div className="db-agent-card">
|
||||
<div className="db-agent-card-title">
|
||||
<Icon name="sparkles" size={15} />
|
||||
Browser Harness
|
||||
</div>
|
||||
<p>Turn any opened reference into a saved extraction task for browser-use and artifacts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="db-reference-grid">
|
||||
|
|
@ -824,10 +907,21 @@ function DesignBrowserStart({
|
|||
<h3>{group.title}</h3>
|
||||
<div className="db-reference-list">
|
||||
{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)}>
|
||||
<span>{site.label}</span>
|
||||
<small>{new URL(site.url).hostname.replace(/^www\./, '')}</small>
|
||||
<BrowserSiteIcon
|
||||
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>
|
||||
<p>{site.detail}</p>
|
||||
<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[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
|
|
@ -889,7 +1005,8 @@ export function isHistoryEntry(value: unknown): value is BrowserHistoryEntry {
|
|||
typeof record.url === 'string' &&
|
||||
typeof record.title === 'string' &&
|
||||
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 {
|
||||
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 {
|
||||
const host = labelFromUrl(url).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'page';
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
|
|
|||
|
|
@ -1,24 +1,108 @@
|
|||
// Hand-off menu in the ChatPane header — "open the design project
|
||||
// folder in <local app>". Mirrors paseo's WorkspaceOpenInEditorButton:
|
||||
// a single split-style button that remembers the user's last pick
|
||||
// (LocalStorage) and a dropdown listing the rest. Detection runs on
|
||||
// the daemon; this component just renders.
|
||||
// Hand-off menu in the ChatPane header. The left split button opens the
|
||||
// current design project folder in a local editor, while the dropdown also
|
||||
// exposes copy-to-CLI prompts for handing the same local folder to code agents.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
AgentInfo,
|
||||
HostEditor,
|
||||
HostEditorId,
|
||||
HostEditorsResponse,
|
||||
} from '@open-design/contracts';
|
||||
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 { EditorIcon } from './EditorIcon';
|
||||
import { AgentIcon } from './AgentIcon';
|
||||
|
||||
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 {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
projectDir?: string | null;
|
||||
agents?: AgentInfo[];
|
||||
// Optional fallback "always open in OS file manager" — falls back to the
|
||||
// existing shell.openPath bridge in case the daemon catalogue is empty
|
||||
// (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) {
|
||||
const t = useT();
|
||||
function readPreferredFramework(): string {
|
||||
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 [platform, setPlatform] = useState<HostEditorsResponse['platform']>('unknown');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
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 wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -88,6 +344,14 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copiedTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const available = editors.filter((e) => e.available);
|
||||
const unavailable = editors.filter((e) => !e.available);
|
||||
const preferred = readPreferred();
|
||||
|
|
@ -96,6 +360,10 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
|
|||
const primaryTitle = primary
|
||||
? t('handoff.openInTarget', { target: primary.label })
|
||||
: 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) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -229,45 +531,90 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
|
|||
</div>
|
||||
{open ? (
|
||||
<div className="handoff-menu" role="menu" data-testid="handoff-menu">
|
||||
<div className="handoff-menu-title">{t('handoff.menuTitle')}</div>
|
||||
{available.map((editor) => (
|
||||
<button
|
||||
key={editor.id}
|
||||
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) => (
|
||||
<section className="handoff-menu-block">
|
||||
<div className="handoff-menu-title">{labels.editorSection}</div>
|
||||
<div className="handoff-target-rail handoff-editor-rail">
|
||||
{editorTargets.map((editor) => (
|
||||
<button
|
||||
key={editor.id}
|
||||
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"
|
||||
data-testid={`handoff-menu-item-${editor.id}`}
|
||||
onClick={() => void launch(editor)}
|
||||
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} />
|
||||
<span>{editor.label}</span>
|
||||
<EditorIcon editorId={editor.id} size={24} />
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</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 ? (
|
||||
<>
|
||||
<div className="handoff-menu-divider" />
|
||||
|
|
|
|||
|
|
@ -4297,7 +4297,12 @@ export function ProjectView({
|
|||
>
|
||||
<Icon name="sliders" size={16} />
|
||||
</button>
|
||||
<HandoffButton projectId={project.id} />
|
||||
<HandoffButton
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
projectDir={projectDetail.resolvedDir}
|
||||
agents={agents}
|
||||
/>
|
||||
<AvatarMenu
|
||||
config={config}
|
||||
agents={agents}
|
||||
|
|
|
|||
|
|
@ -1610,23 +1610,32 @@
|
|||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
max-height: min(72vh, 620px);
|
||||
overflow: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.12));
|
||||
padding: 6px;
|
||||
padding: 8px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.app .handoff-menu-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.app .handoff-menu-title {
|
||||
padding: 6px 10px 7px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 11.5px;
|
||||
line-height: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.app .handoff-menu-item {
|
||||
display: inline-flex;
|
||||
|
|
@ -1649,12 +1658,113 @@
|
|||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.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 {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.app .handoff-menu-section {
|
||||
font-size: 10.5px;
|
||||
|
|
|
|||
|
|
@ -239,11 +239,15 @@
|
|||
/* Design browser workspace module. */
|
||||
.design-browser {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
.db-chrome {
|
||||
position: relative;
|
||||
|
|
@ -251,9 +255,11 @@
|
|||
grid-template-columns: auto minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
min-width: 0;
|
||||
padding: 7px 12px;
|
||||
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;
|
||||
}
|
||||
.db-nav,
|
||||
|
|
@ -298,11 +304,11 @@
|
|||
transform: translate(-50%, 0);
|
||||
}
|
||||
.db-icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.db-icon-btn:disabled {
|
||||
|
|
@ -331,20 +337,48 @@
|
|||
}
|
||||
.db-address-form {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
height: 36px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg) 88%, var(--bg-panel));
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
gap: 9px;
|
||||
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 {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border) 55%, var(--text-muted));
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
|
|
@ -365,11 +399,11 @@
|
|||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: min(420px, calc(100vh - 160px));
|
||||
max-height: min(520px, calc(100vh - 136px));
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-panel) 96%, var(--bg));
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow-y: auto;
|
||||
z-index: 220;
|
||||
|
|
@ -382,7 +416,7 @@
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 7px 8px;
|
||||
|
|
@ -390,12 +424,12 @@
|
|||
text-align: left;
|
||||
}
|
||||
.db-suggestions button:hover {
|
||||
background: var(--bg-subtle);
|
||||
background: color-mix(in srgb, var(--bg-subtle) 78%, var(--text) 4%);
|
||||
}
|
||||
.db-suggestion-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
|
|
@ -478,9 +512,11 @@
|
|||
.db-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.db-webview,
|
||||
.db-fallback,
|
||||
|
|
@ -500,25 +536,6 @@
|
|||
.db-fallback iframe {
|
||||
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 {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
|
|
@ -532,7 +549,6 @@
|
|||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.db-fallback-bar button:hover,
|
||||
.db-reference-actions button:hover:not(:disabled) {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
|
@ -540,16 +556,18 @@
|
|||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 28px;
|
||||
background: var(--bg);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
padding: 10px 0 24px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.db-kicker {
|
||||
|
|
@ -560,17 +578,17 @@
|
|||
}
|
||||
.db-start h2 {
|
||||
max-width: 720px;
|
||||
margin: 8px 0 0;
|
||||
margin: 5px 0 0;
|
||||
color: var(--text);
|
||||
font-size: 46px;
|
||||
line-height: 0.96;
|
||||
font-size: 28px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.db-agent-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel);
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, var(--bg-panel) 92%, var(--bg-subtle));
|
||||
padding: 9px 11px;
|
||||
}
|
||||
.db-agent-card-title {
|
||||
display: flex;
|
||||
|
|
@ -589,14 +607,14 @@
|
|||
.db-reference-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
padding-top: 22px;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.db-reference-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.db-reference-group h3 {
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
|
@ -606,21 +624,26 @@
|
|||
.db-reference-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.db-reference-card {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
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);
|
||||
padding: 12px;
|
||||
}
|
||||
.db-reference-card > button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
|
|
@ -628,27 +651,42 @@
|
|||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.db-reference-card > button span,
|
||||
.db-reference-card > button small {
|
||||
.db-reference-icon {
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.db-reference-card > button span {
|
||||
.db-reference-title span {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.db-reference-card > button small {
|
||||
.db-reference-title small {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
.db-reference-card p {
|
||||
margin: 8px 0 12px;
|
||||
min-height: 36px;
|
||||
margin: 7px 0 9px;
|
||||
min-height: 34px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
font-size: 12px;
|
||||
line-height: 1.38;
|
||||
}
|
||||
.db-reference-actions {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import {
|
||||
browserFileName,
|
||||
browserHarnessTaskMarkdown,
|
||||
faviconUrl,
|
||||
formatAddressDisplay,
|
||||
hostnameFromUrl,
|
||||
isHistoryEntry,
|
||||
isHistoryUrl,
|
||||
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', () => {
|
||||
it('accepts http(s) and file URLs', () => {
|
||||
expect(isHistoryUrl('https://example.com')).toBe(true);
|
||||
|
|
@ -189,7 +228,7 @@ describe('browserFileName', () => {
|
|||
describe('isHistoryEntry', () => {
|
||||
it('accepts a well-formed entry', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
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
|
||||
|
|
@ -133,4 +141,86 @@ describe('DesignBrowserPanel <webview> navigation', () => {
|
|||
expect(loadURL).toHaveBeenCalledWith('https://example.com/');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,20 +10,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { HandoffButton } from '../../src/components/HandoffButton';
|
||||
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 openProjectInEditor = vi.fn();
|
||||
const copyToClipboard = vi.fn();
|
||||
|
||||
vi.mock('../../src/providers/registry', () => ({
|
||||
fetchHostEditors: () => fetchHostEditors(),
|
||||
openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/copy-to-clipboard', () => ({
|
||||
copyToClipboard: (...args: unknown[]) => copyToClipboard(...args),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
fetchHostEditors.mockReset();
|
||||
openProjectInEditor.mockReset();
|
||||
copyToClipboard.mockReset();
|
||||
});
|
||||
|
||||
describe('HandoffButton zero-editors fallback', () => {
|
||||
|
|
@ -69,4 +75,54 @@ describe('HandoffButton zero-editors fallback', () => {
|
|||
const errorEl = await screen.findByTestId('handoff-fallback-error');
|
||||
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('真实可运行');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue