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
47 changes: 31 additions & 16 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type ProjectId,
GitActionFailure as GitActionFailureSchema,
type GitActionFailure,
type GitActionProgressEvent,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -355,8 +359,13 @@ function GitSyncActionIcon() {
return <ArrowUpDownIcon className="size-3.5" />;
}

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),
Expand Down Expand Up @@ -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({
Expand All @@ -593,15 +593,20 @@ 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",
description: err instanceof Error ? err.message : "An error occurred.",
data: threadToastData,
});
});
}, [openPullRequest, threadToastData]);
}, [activeProjectId, activeThreadId, openPullRequest, setPreviewOpen, threadToastData]);

const copyOpenPullRequestNumber = useCallback(() => {
if (!openPullRequest) return;
Expand Down Expand Up @@ -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,
});
});
},
},
}
Expand Down
30 changes: 24 additions & 6 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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";
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";

Expand Down Expand Up @@ -58,7 +59,7 @@ interface ChatHeaderProps {
export const ChatHeader = memo(function ChatHeader({
activeThreadId,
activeThreadTitle,
activeProjectId: _activeProjectId,
activeProjectId,
activeProjectName,
activeProjectCwd,
isLocalDraftThread,
Expand Down Expand Up @@ -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 (
<div className="flex min-w-0 flex-1 items-center gap-2">
Expand Down Expand Up @@ -217,7 +231,11 @@ export const ChatHeader = memo(function ChatHeader({
</Tooltip>
)}
{!isMobileCompanion && activeProjectName && (
<GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />
<GitActionsControl
gitCwd={gitCwd}
activeThreadId={activeThreadId}
activeProjectId={activeProjectId ?? null}
/>
)}
{/* Overflow menu: all panel toggles consolidated */}
{!isMobileCompanion && (
Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/components/sme/SmeChatWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/lib/openGitHubUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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<DesktopBridge["preview"]["createTab"]>().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<NativeApi["shell"]["openExternal"]>().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<NativeApi["shell"]["openExternal"]>().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);
});
});
60 changes: 60 additions & 0 deletions apps/web/src/lib/openGitHubUrl.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading