Skip to content

Commit b4e74e3

Browse files
authored
Merge pull request #24 from OpenKnots/okcode/terminal-url-preview
Open terminal URLs in preview or browser
2 parents e7ddb24 + bc01432 commit b4e74e3

8 files changed

Lines changed: 123 additions & 12 deletions

File tree

apps/desktop/src/preview.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type {
44
DesktopPreviewErrorCode,
55
DesktopPreviewState,
66
} from "@okcode/contracts";
7-
import { sanitizeLocalPreviewBounds, validateLocalPreviewUrl } from "@okcode/shared/preview";
7+
import {
8+
sanitizeLocalPreviewBounds,
9+
validateHttpPreviewUrl,
10+
validateLocalPreviewUrl,
11+
} from "@okcode/shared/preview";
812

913
const CLOSED_PREVIEW_STATE: DesktopPreviewState = {
1014
status: "closed",
@@ -38,6 +42,16 @@ export function createPreviewErrorState(
3842

3943
export function validateDesktopPreviewUrl(
4044
rawUrl: unknown,
45+
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
46+
return validateHttpPreviewUrl(rawUrl);
47+
}
48+
49+
/**
50+
* Stricter validation that only allows localhost URLs.
51+
* Kept for contexts where only local dev servers should be previewed.
52+
*/
53+
export function validateDesktopLocalPreviewUrl(
54+
rawUrl: unknown,
4155
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
4256
return validateLocalPreviewUrl(rawUrl);
4357
}

apps/web/src/appSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const AppSettingsSchema = Schema.Struct({
6464
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
6565
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
6666
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
67+
openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)),
6768
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
6869
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),
6970
),

apps/web/src/components/ChatView.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
272272
const setPreviewDock = usePreviewStateStore((state) => state.setThreadDock);
273273
const togglePreviewLayout = usePreviewStateStore((state) => state.toggleThreadLayout);
274274
const setPreviewSize = usePreviewStateStore((state) => state.setThreadSize);
275+
const setPreviewProjectUrl = usePreviewStateStore((state) => state.setProjectUrl);
275276
const previewSplitRef = useRef<HTMLDivElement | null>(null);
276277
const previewResizeStateRef = useRef<{
277278
pointerId: number;
@@ -1299,6 +1300,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
12991300
},
13001301
[activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
13011302
);
1303+
const handlePreviewUrl = useCallback(
1304+
(url: string) => {
1305+
if (!activeProject || !activeThread) return;
1306+
setPreviewProjectUrl(activeProject.id, url);
1307+
setPreviewOpen(activeThread.id, true);
1308+
},
1309+
[activeProject, activeThread, setPreviewProjectUrl, setPreviewOpen],
1310+
);
1311+
const openLinksExternally = settings.openLinksExternally;
1312+
const onPreviewUrl =
1313+
isElectron && activeProject && !openLinksExternally ? handlePreviewUrl : undefined;
13021314
const setTerminalOpen = useCallback(
13031315
(open: boolean) => {
13041316
if (!activeThreadId) return;
@@ -4657,6 +4669,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
46574669
onCloseTerminal={closeTerminal}
46584670
onHeightChange={setTerminalHeight}
46594671
onAddTerminalContext={addTerminalContextToDraft}
4672+
onPreviewUrl={onPreviewUrl}
46604673
/>
46614674
);
46624675
})()}

apps/web/src/components/PreviewPanel.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
XIcon,
1010
} from "lucide-react";
1111

12-
import { readDesktopPreviewBridge, validateLocalPreviewUrl } from "~/desktopPreview";
12+
import { readDesktopPreviewBridge } from "~/desktopPreview";
13+
import { validateHttpPreviewUrl } from "@okcode/shared/preview";
1314
import { readNativeApi } from "~/nativeApi";
1415
import { usePreviewStateStore } from "~/previewStateStore";
1516

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

187188
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
188189
event.preventDefault();
189-
const validatedUrl = validateLocalPreviewUrl(inputUrl);
190+
const validatedUrl = validateHttpPreviewUrl(inputUrl);
190191
if (!validatedUrl.ok) {
191192
setInputError(validatedUrl.error.message);
192193
return;

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ interface TerminalViewportProps {
188188
runtimeEnv?: Record<string, string>;
189189
onSessionExited: () => void;
190190
onAddTerminalContext: (selection: TerminalContextSelection) => void;
191+
onPreviewUrl?: ((url: string) => void) | undefined;
191192
focusRequestId: number;
192193
autoFocus: boolean;
193194
resizeEpoch: number;
@@ -202,6 +203,7 @@ function TerminalViewport({
202203
runtimeEnv,
203204
onSessionExited,
204205
onAddTerminalContext,
206+
onPreviewUrl,
205207
focusRequestId,
206208
autoFocus,
207209
resizeEpoch,
@@ -212,6 +214,7 @@ function TerminalViewport({
212214
const fitAddonRef = useRef<FitAddon | null>(null);
213215
const onSessionExitedRef = useRef(onSessionExited);
214216
const onAddTerminalContextRef = useRef(onAddTerminalContext);
217+
const onPreviewUrlRef = useRef(onPreviewUrl);
215218
const terminalLabelRef = useRef(terminalLabel);
216219
const hasHandledExitRef = useRef(false);
217220
const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
@@ -228,6 +231,10 @@ function TerminalViewport({
228231
onAddTerminalContextRef.current = onAddTerminalContext;
229232
}, [onAddTerminalContext]);
230233

234+
useEffect(() => {
235+
onPreviewUrlRef.current = onPreviewUrl;
236+
}, [onPreviewUrl]);
237+
231238
useEffect(() => {
232239
terminalLabelRef.current = terminalLabel;
233240
}, [terminalLabel]);
@@ -393,12 +400,17 @@ function TerminalViewport({
393400
if (!latestTerminal) return;
394401

395402
if (match.kind === "url") {
396-
void api.shell.openExternal(match.text).catch((error) => {
397-
writeSystemMessage(
398-
latestTerminal,
399-
error instanceof Error ? error.message : "Unable to open link",
400-
);
401-
});
403+
const previewHandler = onPreviewUrlRef.current;
404+
if (previewHandler) {
405+
previewHandler(match.text);
406+
} else {
407+
void api.shell.openExternal(match.text).catch((error) => {
408+
writeSystemMessage(
409+
latestTerminal,
410+
error instanceof Error ? error.message : "Unable to open link",
411+
);
412+
});
413+
}
402414
return;
403415
}
404416

@@ -662,6 +674,7 @@ interface ThreadTerminalDrawerProps {
662674
onCloseTerminal: (terminalId: string) => void;
663675
onHeightChange: (height: number) => void;
664676
onAddTerminalContext: (selection: TerminalContextSelection) => void;
677+
onPreviewUrl?: ((url: string) => void) | undefined;
665678
}
666679

667680
interface TerminalActionButtonProps {
@@ -712,6 +725,7 @@ export default function ThreadTerminalDrawer({
712725
onCloseTerminal,
713726
onHeightChange,
714727
onAddTerminalContext,
728+
onPreviewUrl,
715729
}: ThreadTerminalDrawerProps) {
716730
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
717731
const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -1013,6 +1027,7 @@ export default function ThreadTerminalDrawer({
10131027
{...(runtimeEnv ? { runtimeEnv } : {})}
10141028
onSessionExited={() => onCloseTerminal(terminalId)}
10151029
onAddTerminalContext={onAddTerminalContext}
1030+
onPreviewUrl={onPreviewUrl}
10161031
focusRequestId={focusRequestId}
10171032
autoFocus={terminalId === resolvedActiveTerminalId}
10181033
resizeEpoch={resizeEpoch}
@@ -1033,6 +1048,7 @@ export default function ThreadTerminalDrawer({
10331048
{...(runtimeEnv ? { runtimeEnv } : {})}
10341049
onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
10351050
onAddTerminalContext={onAddTerminalContext}
1051+
onPreviewUrl={onPreviewUrl}
10361052
focusRequestId={focusRequestId}
10371053
autoFocus
10381054
resizeEpoch={resizeEpoch}

apps/web/src/desktopPreview.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DesktopBridge } from "@okcode/contracts";
2-
import { validateLocalPreviewUrl } from "@okcode/shared/preview";
2+
import { validateHttpPreviewUrl, validateLocalPreviewUrl } from "@okcode/shared/preview";
33

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

15-
export { validateLocalPreviewUrl };
15+
export { validateHttpPreviewUrl, validateLocalPreviewUrl };

apps/web/src/routes/_chat.settings.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ function SettingsRouteView() {
258258
...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
259259
? ["Assistant output"]
260260
: []),
261+
...(settings.openLinksExternally !== defaults.openLinksExternally
262+
? ["Open links externally"]
263+
: []),
261264
...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []),
262265
...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
263266
? ["Delete confirmation"]
@@ -559,6 +562,34 @@ function SettingsRouteView() {
559562
}
560563
/>
561564

565+
<SettingsRow
566+
title="Open links externally"
567+
description="Open terminal URLs in your default browser instead of the embedded preview panel."
568+
resetAction={
569+
settings.openLinksExternally !== defaults.openLinksExternally ? (
570+
<SettingResetButton
571+
label="open links externally"
572+
onClick={() =>
573+
updateSettings({
574+
openLinksExternally: defaults.openLinksExternally,
575+
})
576+
}
577+
/>
578+
) : null
579+
}
580+
control={
581+
<Switch
582+
checked={settings.openLinksExternally}
583+
onCheckedChange={(checked) =>
584+
updateSettings({
585+
openLinksExternally: Boolean(checked),
586+
})
587+
}
588+
aria-label="Open links externally"
589+
/>
590+
}
591+
/>
592+
562593
<SettingsRow
563594
title="New threads"
564595
description="Pick the default workspace mode for newly created draft threads."

packages/shared/src/preview.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,41 @@ export function validateLocalPreviewUrl(
5252
return { ok: true, url: parsedUrl.toString() };
5353
}
5454

55+
/**
56+
* Validates any http or https URL for use in the embedded preview panel.
57+
* This is less restrictive than `validateLocalPreviewUrl` – it accepts any
58+
* valid http/https URL, not only localhost addresses.
59+
*/
60+
export function validateHttpPreviewUrl(
61+
rawUrl: unknown,
62+
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
63+
if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) {
64+
return {
65+
ok: false,
66+
error: makePreviewError("invalid-url", "Preview URL must be a non-empty string."),
67+
};
68+
}
69+
70+
let parsedUrl: URL;
71+
try {
72+
parsedUrl = new URL(rawUrl);
73+
} catch {
74+
return {
75+
ok: false,
76+
error: makePreviewError("invalid-url", "Preview URL is not a valid URL."),
77+
};
78+
}
79+
80+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
81+
return {
82+
ok: false,
83+
error: makePreviewError("invalid-url", "Preview only supports http and https URLs."),
84+
};
85+
}
86+
87+
return { ok: true, url: parsedUrl.toString() };
88+
}
89+
5590
export function sanitizeLocalPreviewBounds(bounds: DesktopPreviewBounds): DesktopPreviewBounds {
5691
const width = Number.isFinite(bounds.width) ? Math.max(0, Math.round(bounds.width)) : 0;
5792
const height = Number.isFinite(bounds.height) ? Math.max(0, Math.round(bounds.height)) : 0;

0 commit comments

Comments
 (0)