Skip to content
Merged
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
16 changes: 15 additions & 1 deletion apps/desktop/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type {
DesktopPreviewErrorCode,
DesktopPreviewState,
} from "@okcode/contracts";
import { sanitizeLocalPreviewBounds, validateLocalPreviewUrl } from "@okcode/shared/preview";
import {
sanitizeLocalPreviewBounds,
validateHttpPreviewUrl,
validateLocalPreviewUrl,
} from "@okcode/shared/preview";

const CLOSED_PREVIEW_STATE: DesktopPreviewState = {
status: "closed",
Expand Down Expand Up @@ -38,6 +42,16 @@ export function createPreviewErrorState(

export function validateDesktopPreviewUrl(
rawUrl: unknown,
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
return validateHttpPreviewUrl(rawUrl);
}

/**
* Stricter validation that only allows localhost URLs.
* Kept for contexts where only local dev servers should be previewed.
*/
export function validateDesktopLocalPreviewUrl(
rawUrl: unknown,
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
return validateLocalPreviewUrl(rawUrl);
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const AppSettingsSchema = Schema.Struct({
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)),
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),
),
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setPreviewDock = usePreviewStateStore((state) => state.setThreadDock);
const togglePreviewLayout = usePreviewStateStore((state) => state.toggleThreadLayout);
const setPreviewSize = usePreviewStateStore((state) => state.setThreadSize);
const setPreviewProjectUrl = usePreviewStateStore((state) => state.setProjectUrl);
const previewSplitRef = useRef<HTMLDivElement | null>(null);
const previewResizeStateRef = useRef<{
pointerId: number;
Expand Down Expand Up @@ -1285,6 +1286,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
);
const handlePreviewUrl = useCallback(
(url: string) => {
if (!activeProject || !activeThread) return;
setPreviewProjectUrl(activeProject.id, url);
setPreviewOpen(activeThread.id, true);
},
[activeProject, activeThread, setPreviewProjectUrl, setPreviewOpen],
);
const openLinksExternally = settings.openLinksExternally;
const onPreviewUrl =
isElectron && activeProject && !openLinksExternally ? handlePreviewUrl : undefined;
const setTerminalOpen = useCallback(
(open: boolean) => {
if (!activeThreadId) return;
Expand Down Expand Up @@ -4643,6 +4655,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onCloseTerminal={closeTerminal}
onHeightChange={setTerminalHeight}
onAddTerminalContext={addTerminalContextToDraft}
onPreviewUrl={onPreviewUrl}
/>
);
})()}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
XIcon,
} from "lucide-react";

import { readDesktopPreviewBridge, validateLocalPreviewUrl } from "~/desktopPreview";
import { readDesktopPreviewBridge } from "~/desktopPreview";
import { validateHttpPreviewUrl } from "@okcode/shared/preview";
import { readNativeApi } from "~/nativeApi";
import { usePreviewStateStore } from "~/previewStateStore";

Expand All @@ -35,7 +36,7 @@ export function resolvePreviewStatusCopy(state: DesktopPreviewState): string {
case "ready":
return state.url ? `Rendering ${state.url}` : "Preview ready.";
case "closed":
return "Enter a localhost URL to preview your app inside OK Code.";
return "Enter a URL to preview inside OK Code.";
case "error":
return "Preview failed.";
}
Expand Down Expand Up @@ -186,7 +187,7 @@ export function PreviewPanel({ threadId, projectId, projectName, onClose }: Prev

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const validatedUrl = validateLocalPreviewUrl(inputUrl);
const validatedUrl = validateHttpPreviewUrl(inputUrl);
if (!validatedUrl.ok) {
setInputError(validatedUrl.error.message);
return;
Expand Down
28 changes: 22 additions & 6 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ interface TerminalViewportProps {
runtimeEnv?: Record<string, string>;
onSessionExited: () => void;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
onPreviewUrl?: ((url: string) => void) | undefined;
focusRequestId: number;
autoFocus: boolean;
resizeEpoch: number;
Expand All @@ -202,6 +203,7 @@ function TerminalViewport({
runtimeEnv,
onSessionExited,
onAddTerminalContext,
onPreviewUrl,
focusRequestId,
autoFocus,
resizeEpoch,
Expand All @@ -212,6 +214,7 @@ function TerminalViewport({
const fitAddonRef = useRef<FitAddon | null>(null);
const onSessionExitedRef = useRef(onSessionExited);
const onAddTerminalContextRef = useRef(onAddTerminalContext);
const onPreviewUrlRef = useRef(onPreviewUrl);
const terminalLabelRef = useRef(terminalLabel);
const hasHandledExitRef = useRef(false);
const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
Expand All @@ -228,6 +231,10 @@ function TerminalViewport({
onAddTerminalContextRef.current = onAddTerminalContext;
}, [onAddTerminalContext]);

useEffect(() => {
onPreviewUrlRef.current = onPreviewUrl;
}, [onPreviewUrl]);

useEffect(() => {
terminalLabelRef.current = terminalLabel;
}, [terminalLabel]);
Expand Down Expand Up @@ -393,12 +400,17 @@ function TerminalViewport({
if (!latestTerminal) return;

if (match.kind === "url") {
void api.shell.openExternal(match.text).catch((error) => {
writeSystemMessage(
latestTerminal,
error instanceof Error ? error.message : "Unable to open link",
);
});
const previewHandler = onPreviewUrlRef.current;
if (previewHandler) {
previewHandler(match.text);
} else {
void api.shell.openExternal(match.text).catch((error) => {
writeSystemMessage(
latestTerminal,
error instanceof Error ? error.message : "Unable to open link",
);
});
}
return;
}

Expand Down Expand Up @@ -662,6 +674,7 @@ interface ThreadTerminalDrawerProps {
onCloseTerminal: (terminalId: string) => void;
onHeightChange: (height: number) => void;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
onPreviewUrl?: ((url: string) => void) | undefined;
}

interface TerminalActionButtonProps {
Expand Down Expand Up @@ -712,6 +725,7 @@ export default function ThreadTerminalDrawer({
onCloseTerminal,
onHeightChange,
onAddTerminalContext,
onPreviewUrl,
}: ThreadTerminalDrawerProps) {
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
const [resizeEpoch, setResizeEpoch] = useState(0);
Expand Down Expand Up @@ -1013,6 +1027,7 @@ export default function ThreadTerminalDrawer({
{...(runtimeEnv ? { runtimeEnv } : {})}
onSessionExited={() => onCloseTerminal(terminalId)}
onAddTerminalContext={onAddTerminalContext}
onPreviewUrl={onPreviewUrl}
focusRequestId={focusRequestId}
autoFocus={terminalId === resolvedActiveTerminalId}
resizeEpoch={resizeEpoch}
Expand All @@ -1033,6 +1048,7 @@ export default function ThreadTerminalDrawer({
{...(runtimeEnv ? { runtimeEnv } : {})}
onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
onAddTerminalContext={onAddTerminalContext}
onPreviewUrl={onPreviewUrl}
focusRequestId={focusRequestId}
autoFocus
resizeEpoch={resizeEpoch}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/desktopPreview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DesktopBridge } from "@okcode/contracts";
import { validateLocalPreviewUrl } from "@okcode/shared/preview";
import { validateHttpPreviewUrl, validateLocalPreviewUrl } from "@okcode/shared/preview";

export function readDesktopPreviewBridge(): DesktopBridge["preview"] | null {
if (typeof window === "undefined") {
Expand All @@ -12,4 +12,4 @@ export function canUseDesktopPreview(): boolean {
return readDesktopPreviewBridge() !== null;
}

export { validateLocalPreviewUrl };
export { validateHttpPreviewUrl, validateLocalPreviewUrl };
31 changes: 31 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ function SettingsRouteView() {
...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
? ["Assistant output"]
: []),
...(settings.openLinksExternally !== defaults.openLinksExternally
? ["Open links externally"]
: []),
...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []),
...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
? ["Delete confirmation"]
Expand Down Expand Up @@ -559,6 +562,34 @@ function SettingsRouteView() {
}
/>

<SettingsRow
title="Open links externally"
description="Open terminal URLs in your default browser instead of the embedded preview panel."
resetAction={
settings.openLinksExternally !== defaults.openLinksExternally ? (
<SettingResetButton
label="open links externally"
onClick={() =>
updateSettings({
openLinksExternally: defaults.openLinksExternally,
})
}
/>
) : null
}
control={
<Switch
checked={settings.openLinksExternally}
onCheckedChange={(checked) =>
updateSettings({
openLinksExternally: Boolean(checked),
})
}
aria-label="Open links externally"
/>
}
/>

<SettingsRow
title="New threads"
description="Pick the default workspace mode for newly created draft threads."
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,41 @@ export function validateLocalPreviewUrl(
return { ok: true, url: parsedUrl.toString() };
}

/**
* Validates any http or https URL for use in the embedded preview panel.
* This is less restrictive than `validateLocalPreviewUrl` – it accepts any
* valid http/https URL, not only localhost addresses.
*/
export function validateHttpPreviewUrl(
rawUrl: unknown,
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) {
return {
ok: false,
error: makePreviewError("invalid-url", "Preview URL must be a non-empty string."),
};
}

let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
return {
ok: false,
error: makePreviewError("invalid-url", "Preview URL is not a valid URL."),
};
}

if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return {
ok: false,
error: makePreviewError("invalid-url", "Preview only supports http and https URLs."),
};
}

return { ok: true, url: parsedUrl.toString() };
}

export function sanitizeLocalPreviewBounds(bounds: DesktopPreviewBounds): DesktopPreviewBounds {
const width = Number.isFinite(bounds.width) ? Math.max(0, Math.round(bounds.width)) : 0;
const height = Number.isFinite(bounds.height) ? Math.max(0, Math.round(bounds.height)) : 0;
Expand Down
Loading