Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,6 @@ storybook-static
nul

backend/benchmark/jobs/

# TypeScript incremental build info
*.tsbuildinfo
26 changes: 26 additions & 0 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2379,6 +2379,32 @@ async function createWindow() {
},
});

// Renderer <webview> guests (session preview browser) host arbitrary web
// content, and the host window itself runs with elevated webPreferences.
// Enforce safe guest settings at attach time so no tag attribute (even one
// forged by a compromised renderer) can grant a guest host privileges.
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
webPreferences.webSecurity = true;
if (!/^https?:\/\//i.test(params.src ?? '')) {
event.preventDefault();
}
});

// Route window.open / target=_blank into the same guest instead of spawning
// popup windows, and only allow web URLs. Together with the attach guard
// above, this is the only main-process involvement the guests need.
win.webContents.on('did-attach-webview', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) {
void contents.loadURL(url);
}
return { action: 'deny' };
});
});

if (process.platform === 'darwin') {
win.once('ready-to-show', () => {
if (win && !win.isDestroyed()) {
Expand Down
19 changes: 18 additions & 1 deletion src/components/ChatBox/MessageItem/MarkDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const MarkDown = memo(
const host = useHost();
const electronAPI = host?.electronAPI;
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const [displayedContent, setDisplayedContent] = useState('');
const [html, setHtml] = useState('');
const [previewImage, setPreviewImage] = useState<string | null>(null);
Expand Down Expand Up @@ -298,6 +299,22 @@ export const MarkDown = memo(
if (filePath) {
openFilePreview(fileInfoFromPath(filePath));
}
return;
}
// Web links stay inside the session: open them in the preview
// browser of this project. (On the web host, where no embedded
// browser exists, fall back to a regular browser tab.)
const link = target.closest('a[href]');
if (link) {
const href = link.getAttribute('href') ?? '';
if (/^https?:\/\//i.test(href)) {
e.preventDefault();
if (electronAPI) {
openBrowserPreview(href);
} else {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}
};

Expand All @@ -307,7 +324,7 @@ export const MarkDown = memo(
return () => {
div.removeEventListener('click', handleContentClick);
};
}, [html, openFilePreview]);
}, [html, openFilePreview, openBrowserPreview, electronAPI]);

return (
<>
Expand Down
30 changes: 24 additions & 6 deletions src/components/ChatBox/MessageItem/UserMessageRichContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
tokenizeRichPlainText,
} from '@/lib/richText';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { Fragment, type ReactNode } from 'react';

/** Same tokens as `UserMessageCard` body (13px / 20px). */
Expand Down Expand Up @@ -58,7 +59,13 @@ function parseContentWithTags(content: string): ContentNode[] {
return nodes.length > 0 ? nodes : [{ type: 'text', value: content }];
}

function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
function renderMessageRichSegments(
text: string,
keyPrefix: string,
/** When set, URL clicks open here (the session's preview browser) instead
* of following the anchor out of the app. */
onOpenUrl?: (url: string) => void
): ReactNode {
return tokenizeRichPlainText(text).map((seg, i) => {
const key = `${keyPrefix}-${i}`;
if (seg.type === 'text') {
Expand All @@ -73,8 +80,14 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ds-text-information-default-default decoration-ds-border-information-default-default underline underline-offset-2"
onClick={(e) => e.stopPropagation()}
className="text-ds-text-information-default-default underline decoration-ds-border-information-default-default underline-offset-2"
onClick={(e) => {
e.stopPropagation();
if (onOpenUrl) {
e.preventDefault();
onOpenUrl(href);
}
}}
>
{seg.text}
</a>
Expand All @@ -87,7 +100,7 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
<span
key={key}
className={cn(
'rounded px-0.5 font-normal inline align-baseline',
'inline rounded px-0.5 align-baseline font-normal',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
Expand Down Expand Up @@ -115,13 +128,18 @@ export function UserMessageRichContent({
className,
}: UserMessageRichContentProps) {
const host = useHost();
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const contentNodes = parseContentWithTags(content);

const handleOpenSkillFolder = (skillName: string) => {
if (!isSafeSkillFolderName(skillName)) return;
host?.electronAPI?.openSkillFolder?.(skillName);
};

// Desktop: links open in this project's preview browser; on the web host
// (no embedded browser) the anchor's target=_blank fallback applies.
const handleOpenUrl = host?.electronAPI ? openBrowserPreview : undefined;

const bodyClass =
variant === 'card'
? 'text-ds-text-neutral-default-default font-sans relative z-0 break-words whitespace-pre-wrap'
Expand All @@ -134,7 +152,7 @@ export function UserMessageRichContent({
if (node.type === 'text') {
return (
<Fragment key={i}>
{renderMessageRichSegments(node.value, `n${i}`)}
{renderMessageRichSegments(node.value, `n${i}`, handleOpenUrl)}
</Fragment>
);
}
Expand All @@ -151,7 +169,7 @@ export function UserMessageRichContent({
}}
title="Open skill folder"
className={cn(
'mx-0 rounded px-0.5 font-normal inline cursor-pointer align-baseline [font:inherit] hover:opacity-90',
'mx-0 inline cursor-pointer rounded px-0.5 align-baseline font-normal [font:inherit] hover:opacity-90',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
Expand Down
6 changes: 5 additions & 1 deletion src/components/Folder/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface FilePreviewProps {
file: FileInfo | null;
/** Outer surface background class (project page uses default-default). */
surfaceClassName?: string;
/** Remove the standalone rounded/margin frame when hosted in a tab shell. */
embedded?: boolean;
/** Sibling project files, used by the HTML renderer to resolve local assets. */
projectFiles?: FileInfo[];
/** Close the preview column. */
Expand All @@ -51,6 +53,7 @@ export interface FilePreviewProps {
export function FilePreview({
file,
surfaceClassName = 'bg-ds-bg-neutral-default-default',
embedded = false,
projectFiles = [],
onClose,
onJumpToContext,
Expand Down Expand Up @@ -248,11 +251,12 @@ export function FilePreview({
}
projectFiles={projectFiles}
surfaceClassName={surfaceClassName}
embedded={embedded}
onRevealFile={handleRevealFile}
onDownloadFile={handleDownloadFile}
onToggleSourceCode={handleToggleSourceCode}
emptyState={
<div className="gap-3 px-6 text-ds-text-neutral-muted-default flex h-full w-full flex-1 flex-col items-center justify-center text-center">
<div className="flex h-full w-full flex-1 flex-col items-center justify-center gap-3 px-6 text-center text-ds-text-neutral-muted-default">
<FileText className="h-12 w-12 text-ds-icon-neutral-muted-default" />
<p className="text-sm">
{t('chat.no-file-selected', {
Expand Down
Loading
Loading