mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 02:06:21 +00:00
* fix(desktop): open external URL artifacts and markdown links The Web Shell relied on the webview's implicit target="_blank" / window.open handling for every external link. In the packaged desktop shell that path silently drops failed new-window requests, so link artifacts (type: link, storage: external_url) and markdown links in assistant messages appeared styled but dead, with no error feedback. Add an explicit open_external_url command to the Tauri shell (validated against http/https/mailto, opened via the OS default browser) and route external clicks through it when the Web Shell detects the desktop bridge. The artifact details panel gains an "Open link" action next to the Location URL, and failures surface as error toasts instead of silent no-ops. Plain browsers keep native anchor behavior. Fixes #9060; follows up on #8593 for the Tauri shell. * test(web-shell): keep real ToastHost exports in App mock The App test suite replaces ToastHost with a stub; after adding TOAST_REQUEST_EVENT/requestToast to the module, the partial mock broke every App test. Spread importActual so new exports stay available. * fix(desktop): declare external URL permission * fix(desktop): authorize remote external URL opens * fix(desktop): normalize external opener URLs * test(web-shell): pin desktop link click handling * fix(desktop): route modified URL clicks
105 lines
3.1 KiB
TypeScript
105 lines
3.1 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useI18n } from '../i18n';
|
|
import { useWebShellPortalRoot } from '../portalRoot';
|
|
import styles from './ToastHost.module.css';
|
|
|
|
export type ToastTone = 'info' | 'warning' | 'error' | 'success';
|
|
|
|
/** Window event that asks the app-level toast host to show a toast. Lets
|
|
* deeply nested components (markdown links, artifact actions) report failures
|
|
* without prop-drilling the toast callback. */
|
|
export const TOAST_REQUEST_EVENT = 'qwen:toast-request';
|
|
|
|
export interface ToastRequestDetail {
|
|
tone: ToastTone;
|
|
message: string;
|
|
}
|
|
|
|
export function requestToast(tone: ToastTone, message: string): void {
|
|
window.dispatchEvent(
|
|
new CustomEvent<ToastRequestDetail>(TOAST_REQUEST_EVENT, {
|
|
detail: { tone, message },
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface WebShellToast {
|
|
id: string;
|
|
tone: ToastTone;
|
|
message: string;
|
|
/** Epoch ms when the toast auto-dismisses; survives host remounts. */
|
|
dismissAt: number;
|
|
}
|
|
|
|
interface ToastHostProps {
|
|
toasts: readonly WebShellToast[];
|
|
onDismiss: (id: string) => void;
|
|
/** Paint above dialog-backdrop-tier surfaces (fullscreen artifact panel). */
|
|
elevated?: boolean;
|
|
}
|
|
|
|
export function ToastHost({
|
|
toasts,
|
|
onDismiss,
|
|
elevated = false,
|
|
}: ToastHostProps) {
|
|
const portalRoot = useWebShellPortalRoot();
|
|
if (toasts.length === 0) return null;
|
|
const host = (
|
|
<div
|
|
className={`${styles.host} ${elevated ? styles.hostElevated : ''}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
data-web-shell-toast-host
|
|
>
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
|
|
))}
|
|
</div>
|
|
);
|
|
// While elevated the host must share the portal root's stacking context:
|
|
// in shadow-DOM portal mode the fullscreen drawer surface is sealed inside
|
|
// the portal host (z = --web-shell-portal-root-z-index), so a toast left in
|
|
// the app tree paints beneath it for its whole auto-dismiss lifetime.
|
|
if (elevated && portalRoot) return createPortal(host, portalRoot);
|
|
return host;
|
|
}
|
|
|
|
function ToastItem({
|
|
toast,
|
|
onDismiss,
|
|
}: {
|
|
toast: WebShellToast;
|
|
onDismiss: (id: string) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
useEffect(() => {
|
|
// Schedule against the deadline, not a fresh duration: toggling
|
|
// `elevated` moves the host between the app tree and the portal root,
|
|
// remounting this item, and a full new timer per remount would keep a
|
|
// toast on screen indefinitely across repeated toggles.
|
|
const delay = Math.max(0, toast.dismissAt - Date.now());
|
|
const timer = window.setTimeout(() => onDismiss(toast.id), delay);
|
|
return () => window.clearTimeout(timer);
|
|
}, [onDismiss, toast.id, toast.dismissAt]);
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.toast} ${styles[toast.tone]}`}
|
|
data-web-shell-toast
|
|
data-tone={toast.tone}
|
|
>
|
|
<div className={styles.message}>{toast.message}</div>
|
|
<button
|
|
type="button"
|
|
className={styles.close}
|
|
onClick={() => onDismiss(toast.id)}
|
|
aria-label={t('toast.dismiss')}
|
|
title={t('toast.dismissShort')}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|