diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 807e33af2..86aa0e739 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -1,4 +1,5 @@
import {
+ type ProjectId,
GitActionFailure as GitActionFailureSchema,
type GitActionFailure,
type GitActionProgressEvent,
@@ -84,14 +85,17 @@ import {
invalidateGitQueries,
} from "~/lib/gitReactQuery";
import { subscribeToGitPullRequestAction } from "~/lib/gitPullRequestAction";
+import { openGitHubUrl } from "~/lib/openGitHubUrl";
import { newCommandId, newMessageId, randomUUID } from "~/lib/utils";
-import { resolvePathLinkTarget } from "~/terminal-links";
import { readNativeApi } from "~/nativeApi";
+import { usePreviewStateStore } from "~/previewStateStore";
+import { resolvePathLinkTarget } from "~/terminal-links";
import { isWsRequestError } from "~/wsTransport";
interface GitActionsControlProps {
gitCwd: string | null;
activeThreadId: ThreadId | null;
+ activeProjectId: ProjectId | null;
}
interface PendingDefaultBranchAction {
@@ -355,8 +359,13 @@ function GitSyncActionIcon() {
return ;
}
-export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
+export default function GitActionsControl({
+ gitCwd,
+ activeThreadId,
+ activeProjectId,
+}: GitActionsControlProps) {
const { settings } = useAppSettings();
+ const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen);
const openFileInViewer = useFileViewNavigation();
const threadToastData = useMemo(
() => (activeThreadId ? { threadId: activeThreadId } : undefined),
@@ -575,15 +584,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
}, [updateActiveProgressToast]);
const openExistingPr = useCallback(async () => {
- const api = readNativeApi();
- if (!api) {
- toastManager.add({
- type: "error",
- title: "Link opening is unavailable.",
- data: threadToastData,
- });
- return;
- }
const prUrl = openPullRequest?.url ?? null;
if (!prUrl) {
toastManager.add({
@@ -593,7 +593,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
});
return;
}
- void api.shell.openExternal(prUrl).catch((err) => {
+ void openGitHubUrl({
+ url: prUrl,
+ projectId: activeProjectId,
+ threadId: activeThreadId,
+ setPreviewOpen,
+ }).catch((err) => {
toastManager.add({
type: "error",
title: "Unable to open PR link",
@@ -601,7 +606,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
data: threadToastData,
});
});
- }, [openPullRequest, threadToastData]);
+ }, [activeProjectId, activeThreadId, openPullRequest, setPreviewOpen, threadToastData]);
const copyOpenPullRequestNumber = useCallback(() => {
if (!openPullRequest) return;
@@ -770,10 +775,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
actionProps: {
children: formatOpenPullRequestLabel(prNumber),
onClick: () => {
- const api = readNativeApi();
- if (!api) return;
closeResultToast();
- void api.shell.openExternal(prUrl);
+ void openGitHubUrl({
+ url: prUrl,
+ projectId: activeProjectId,
+ threadId: activeThreadId,
+ setPreviewOpen,
+ }).catch((err) => {
+ toastManager.add({
+ type: "error",
+ title: "Unable to open PR link",
+ description: err instanceof Error ? err.message : "An error occurred.",
+ data: threadToastData,
+ });
+ });
},
},
}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 6724a20ca..7d7644c61 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -11,7 +11,7 @@ import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
import { shortcutLabelsForCommand } from "~/keybindings";
import type { ClientMode } from "~/lib/clientMode";
import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
-import { ensureNativeApi } from "~/nativeApi";
+import { openGitHubUrl } from "~/lib/openGitHubUrl";
import type { PreviewDock } from "~/previewStateStore";
import type { ProjectScriptDraft } from "~/projectScriptDefaults";
import { EditableThreadTitle } from "../EditableThreadTitle";
@@ -19,6 +19,7 @@ import GitActionsControl from "../GitActionsControl";
import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
+import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { HeaderPanelsMenu } from "./HeaderPanelsMenu";
@@ -58,7 +59,7 @@ interface ChatHeaderProps {
export const ChatHeader = memo(function ChatHeader({
activeThreadId,
activeThreadTitle,
- activeProjectId: _activeProjectId,
+ activeProjectId,
activeProjectName,
activeProjectCwd,
isLocalDraftThread,
@@ -121,9 +122,22 @@ export const ChatHeader = memo(function ChatHeader({
const pullRequestShortcutLabels = shortcutLabelsForCommand(keybindings, "git.pullRequest");
const primaryPullRequestShortcutLabel = pullRequestShortcutLabels[0] ?? null;
- const openPrLink = useCallback((url: string) => {
- void ensureNativeApi().shell.openExternal(url);
- }, []);
+ const openPrLink = useCallback(
+ (url: string) => {
+ void openGitHubUrl({
+ url,
+ projectId: activeProjectId ?? null,
+ threadId: activeThreadId,
+ }).catch((error) => {
+ toastManager.add({
+ type: "error",
+ title: "Unable to open PR link",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ });
+ },
+ [activeProjectId, activeThreadId],
+ );
return (
@@ -217,7 +231,11 @@ export const ChatHeader = memo(function ChatHeader({
)}
{!isMobileCompanion && activeProjectName && (
-
+
)}
{/* Overflow menu: all panel toggles consolidated */}
{!isMobileCompanion && (
diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx
index 06e73bd9c..e5f11ae55 100644
--- a/apps/web/src/components/sme/SmeChatWorkspace.tsx
+++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx
@@ -1,14 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import {
- ArrowUpIcon,
- BookOpenIcon,
- Settings2Icon,
- SparklesIcon,
- XIcon,
-} from "lucide-react";
+import { ArrowUpIcon, BookOpenIcon, Settings2Icon, SparklesIcon, XIcon } from "lucide-react";
import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts";
-import type { RegisteredRouter } from "@tanstack/react-router";
import { getProviderStartOptions, useAppSettings } from "~/appSettings";
import { ProviderHealthBanner } from "~/components/chat/ProviderHealthBanner";
@@ -36,7 +29,6 @@ export function SmeChatWorkspace({
onToggleKnowledge,
knowledgePanelOpen,
}: SmeChatWorkspaceProps) {
- const navigate = useNavigate();
const { settings } = useAppSettings();
const providerOptions = useMemo(() => getProviderStartOptions(settings), [settings]);
const conversations = useSmeStore((state) => state.conversations);
diff --git a/apps/web/src/lib/openGitHubUrl.test.ts b/apps/web/src/lib/openGitHubUrl.test.ts
new file mode 100644
index 000000000..048425267
--- /dev/null
+++ b/apps/web/src/lib/openGitHubUrl.test.ts
@@ -0,0 +1,76 @@
+import type { DesktopBridge, NativeApi } from "@okcode/contracts";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { canOpenGitHubUrlInPreview, openGitHubUrl } from "./openGitHubUrl";
+
+describe("openGitHubUrl", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("opens GitHub URLs in preview when desktop preview context is available", async () => {
+ const createTab = vi.fn().mockResolvedValue({
+ tabId: "tab-1",
+ state: { tabs: [], activeTabId: null, visible: true },
+ });
+ const previewBridge = { createTab } as unknown as DesktopBridge["preview"];
+ const setPreviewOpen = vi.fn();
+ const openExternal = vi.fn().mockResolvedValue(undefined);
+ const nativeApi = { shell: { openExternal } } as unknown as NativeApi;
+
+ const result = await openGitHubUrl({
+ url: "https://github.com/OpenKnots/okcode/pull/42",
+ projectId: "project-1" as never,
+ threadId: "thread-1" as never,
+ previewBridge,
+ nativeApi,
+ setPreviewOpen,
+ });
+
+ expect(result).toBe("preview");
+ expect(setPreviewOpen).toHaveBeenCalledWith("project-1", true);
+ expect(createTab).toHaveBeenCalledWith({
+ url: "https://github.com/OpenKnots/okcode/pull/42",
+ threadId: "thread-1",
+ });
+ expect(openExternal).not.toHaveBeenCalled();
+ });
+
+ it("falls back to external open when preview is unavailable", async () => {
+ const openExternal = vi.fn().mockResolvedValue(undefined);
+ const nativeApi = { shell: { openExternal } } as unknown as NativeApi;
+ const setPreviewOpen = vi.fn();
+
+ const result = await openGitHubUrl({
+ url: "https://github.com/OpenKnots/okcode/issues/42",
+ projectId: "project-1" as never,
+ threadId: null,
+ nativeApi,
+ setPreviewOpen,
+ });
+
+ expect(result).toBe("external");
+ expect(setPreviewOpen).not.toHaveBeenCalled();
+ expect(openExternal).toHaveBeenCalledWith("https://github.com/OpenKnots/okcode/issues/42");
+ });
+
+ it("only treats GitHub http urls as preview eligible", () => {
+ expect(
+ canOpenGitHubUrlInPreview({
+ url: "https://github.com/OpenKnots/okcode/pull/42",
+ projectId: "project-1" as never,
+ threadId: "thread-1" as never,
+ previewBridge: {} as never,
+ }),
+ ).toBe(true);
+
+ expect(
+ canOpenGitHubUrlInPreview({
+ url: "https://example.com/OpenKnots/okcode/pull/42",
+ projectId: "project-1" as never,
+ threadId: "thread-1" as never,
+ previewBridge: {} as never,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/openGitHubUrl.ts b/apps/web/src/lib/openGitHubUrl.ts
new file mode 100644
index 000000000..2431f008a
--- /dev/null
+++ b/apps/web/src/lib/openGitHubUrl.ts
@@ -0,0 +1,60 @@
+import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts";
+
+import { readDesktopPreviewBridge } from "~/desktopPreview";
+import { readNativeApi } from "~/nativeApi";
+import { usePreviewStateStore } from "~/previewStateStore";
+
+export interface OpenGitHubUrlInput {
+ url: string;
+ projectId: ProjectId | null;
+ threadId: ThreadId | null;
+ nativeApi?: NativeApi | undefined;
+ previewBridge?: DesktopBridge["preview"] | null | undefined;
+ setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined;
+}
+
+function isGitHubHttpUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ const hostname = parsed.hostname.toLowerCase();
+ return (
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
+ (hostname === "github.com" || hostname === "www.github.com")
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function canOpenGitHubUrlInPreview(input: OpenGitHubUrlInput): boolean {
+ return (
+ isGitHubHttpUrl(input.url) &&
+ input.projectId !== null &&
+ input.threadId !== null &&
+ (input.previewBridge ?? readDesktopPreviewBridge()) !== null
+ );
+}
+
+export async function openGitHubUrl(input: OpenGitHubUrlInput): Promise<"preview" | "external"> {
+ const previewBridge = input.previewBridge ?? readDesktopPreviewBridge();
+ const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen;
+
+ if (
+ isGitHubHttpUrl(input.url) &&
+ previewBridge !== null &&
+ input.projectId !== null &&
+ input.threadId !== null
+ ) {
+ setPreviewOpen(input.projectId, true);
+ await previewBridge.createTab({ url: input.url, threadId: input.threadId });
+ return "preview";
+ }
+
+ const nativeApi = input.nativeApi ?? readNativeApi();
+ if (!nativeApi) {
+ throw new Error("Link opening is unavailable.");
+ }
+
+ await nativeApi.shell.openExternal(input.url);
+ return "external";
+}