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
8 changes: 7 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import {
} from "../previewMiniPlayerStore";
import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";
import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
import { usePanelPullRequestNavigation } from "./pullRequest/usePanelPullRequestNavigation";
import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";
Expand Down Expand Up @@ -3792,6 +3793,11 @@ export default function ChatView(props: ChatViewProps) {
])
: null;
const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
const openPanelPullRequest = usePanelPullRequestNavigation(
activeThreadRef,
renderedRightPanelSurface,
supportsPullRequests,
);
const openThreadPullRequest = useCallback(
(number: number) => {
if (!supportsPullRequests || !activeThreadRef) {
Expand Down Expand Up @@ -7624,7 +7630,7 @@ export default function ChatView(props: ChatViewProps) {
: "page"
}
composerDraftTarget={composerDraftTarget}
onOpenPullRequest={openThreadPullRequest}
onOpenPullRequest={openPanelPullRequest}
{...(linkedThreadPullRequest === null
? { onStateChange: handlePullRequestTabStatusChange }
: {})}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
pullRequestSurface,
selectActiveRightPanelSurface,
selectThreadRightPanelState,
type RightPanelSurface,
useRightPanelStore,
} from "../../rightPanelStore";
import { usePanelPullRequestNavigation } from "./usePanelPullRequestNavigation";

const threadRef = scopeThreadRef("remote-environment" as EnvironmentId, ThreadId.make("thread"));
const threadPullRequest = pullRequestSurface({
projectId: "thread-project",
repository: "acme/web",
number: 42,
});
const displayedPullRequest = pullRequestSurface({
projectId: "linked-project",
repository: "acme/api",
number: 12,
});
let renderer: ReactTestRenderer | null = null;

function NavigationProbe({ surface }: { surface: RightPanelSurface | null }) {
const openDependency = usePanelPullRequestNavigation(threadRef, surface, true);
return <button onClick={() => openDependency(42)}>Open dependency</button>;
}

function activeSurface() {
return selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef);
}

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
useRightPanelStore.setState({ byThreadKey: {} });
});

afterEach(async () => {
await act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});

describe("thread panel dependency navigation", () => {
it("opens the dependency in the displayed repository and retains the thread's same-number PR", async () => {
useRightPanelStore.getState().openPullRequest(threadRef, threadPullRequest);
useRightPanelStore.getState().openPullRequest(threadRef, displayedPullRequest);
await act(() => {
renderer = create(<NavigationProbe surface={displayedPullRequest} />);
});

await act(() => renderer!.root.findByType("button").props.onClick());

const dependency = pullRequestSurface({
projectId: "linked-project",
repository: "acme/api",
number: 42,
});
expect(activeSurface()).toEqual(dependency);
expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
).toEqual([threadPullRequest, displayedPullRequest, dependency]);

// Switching back to the thread's PR must also switch the dependency destination.
useRightPanelStore.getState().activateSurface(threadRef, threadPullRequest.id);
await act(() => renderer!.update(<NavigationProbe surface={threadPullRequest} />));
await act(() => renderer!.root.findByType("button").props.onClick());
expect(activeSurface()).toEqual(threadPullRequest);
});

it("uses the rendered repository while panel transitions retain the previous surface", async () => {
useRightPanelStore.getState().openPullRequest(threadRef, displayedPullRequest);
await act(() => {
renderer = create(<NavigationProbe surface={displayedPullRequest} />);
});
useRightPanelStore.getState().openPullRequest(threadRef, threadPullRequest);

await act(() => renderer!.root.findByType("button").props.onClick());

expect(activeSurface()).toEqual(
pullRequestSurface({ projectId: "linked-project", repository: "acme/api", number: 42 }),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { useCallback } from "react";

import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore";

/** Dependency numbers belong to the displayed pull request's repository, even beside another project. */
export function usePanelPullRequestNavigation(
threadRef: ScopedThreadRef | null,
surface: RightPanelSurface | null,
supportsPullRequests: boolean,
) {
return useCallback(
(number: number) => {
if (!supportsPullRequests || threadRef === null || surface?.kind !== "pull-request") return;
useRightPanelStore.getState().openPullRequest(threadRef, {
projectId: surface.projectId,
repository: surface.repository,
number,
});
},
[threadRef, surface, supportsPullRequests],
);
}