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
130 changes: 18 additions & 112 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ import {
ThreadErrorBanner,
} from "./chat/ThreadErrorBanner";
import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl";
import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest";
import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import { ThreadStatusLine } from "./chat/ThreadStatusLine";
import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat";
Expand Down Expand Up @@ -4244,120 +4244,26 @@ export default function ChatView(props: ChatViewProps) {
useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
};
const openFileSurfaceStable = useStableCallback(openFileSurface);
const persistedLinkedThreadPullRequest = isServerThread
? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
: (activeThread?.linkedPullRequest ?? null);
// The thread's own change request, placed against the project it belongs to. Without a
// project there is nothing to resolve it against, so the caller falls back to the browser.
const activeProjectRepository = sourceControlRepositorySelector(
activeProject?.repositoryIdentity,
);
const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest(
activeThreadRef?.environmentId ?? null,
persistedLinkedThreadPullRequest,
);
const replacementLinkedThreadPullRequest = useMemo(() => {
const detected = gitStatusQuery.data?.pr;
const threadBranch = activeThread?.branch;
const projectId = activeProject?.id;
if (
persistedLinkedThreadPullRequest === null ||
(persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" &&
persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") ||
gitStatusQuery.data?.refName !== threadBranch ||
detected?.state !== "open" ||
detected.headRef !== threadBranch ||
projectId === undefined ||
activeProjectRepository === null ||
(persistedLinkedThreadPullRequest.projectId === projectId &&
persistedLinkedThreadPullRequest.repository.toLowerCase() ===
activeProjectRepository.toLowerCase() &&
persistedLinkedThreadPullRequest.number === detected.number)
) {
return null;
}
return {
projectId,
repository: activeProjectRepository,
number: detected.number,
url: detected.url,
};
}, [
activeProject?.id,
activeProjectRepository,
activeThread?.branch,
gitStatusQuery.data,
persistedLinkedThreadPullRequest,
persistedLinkedThreadPullRequestStatus?.pr.state,
]);
const linkedThreadPullRequest =
replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest;
const linkedThreadPullRequestKey = linkedThreadPullRequest
? JSON.stringify([
linkedThreadPullRequest.projectId,
linkedThreadPullRequest.repository,
linkedThreadPullRequest.number,
])
: null;
const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
const threadPrRelinkKeysRef = useRef(new Map<string, string>());
const threadPrRelinkWriteRef = useRef(Promise.resolve());
useEffect(() => {
if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
return;
}
if (replacementLinkedThreadPullRequest === null) {
threadPrRelinkKeysRef.current.delete(activeThreadKey);
return;
}
const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);
const openSurface = selectActiveRightPanelSurface(
useRightPanelStore.getState().byThreadKey,
activeThreadRef,
);
if (
openSurface?.kind === "pull-request" &&
persistedLinkedThreadPullRequest !== null &&
openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
openSurface.repository.toLowerCase() ===
persistedLinkedThreadPullRequest.repository.toLowerCase() &&
openSurface.number === persistedLinkedThreadPullRequest.number
) {
useRightPanelStore
.getState()
.openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest);
}

threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
const result = await updateThreadMetadata({
environmentId: activeThreadRef.environmentId,
input: {
threadId: activeThreadRef.threadId,
linkedPullRequest: replacementLinkedThreadPullRequest,
},
});
if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
if (result._tag !== "Failure") return;
threadPrRelinkKeysRef.current.delete(activeThreadKey);
if (isAtomCommandInterrupted(result)) return;
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to update the thread pull request",
description: chatActionErrorMessage(squashAtomCommandFailure(result)),
}),
);
});
}, [
activeThreadKey,
activeThreadRef,
isServerThread,
persistedLinkedThreadPullRequest,
replacementLinkedThreadPullRequest,
updateThreadMetadata,
]);
const hasLinkedPullRequestDetail = persistedLinkedThreadPullRequest !== null;
const {
linked: linkedThreadPullRequest,
linkedKey: linkedThreadPullRequestKey,
hasDetail: hasLinkedPullRequestDetail,
} = useThreadLinkedPullRequest({
serverThreadRef: isServerThread ? activeThreadRef : null,
environmentId: activeThreadRef?.environmentId ?? null,
persisted: isServerThread
? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
: (activeThread?.linkedPullRequest ?? null),
projectId: activeProject?.id ?? null,
projectRepository: activeProjectRepository,
threadBranch: activeThread?.branch ?? null,
gitStatus: gitStatusQuery.data ?? null,
});
const proactivePullRequestsKey = pullRequestsSurfaceAvailable
? JSON.stringify(
visiblePullRequests.map((link) => [link.host, link.repository, link.number]).sort(),
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
type EnvironmentId,
type ProjectId,
type ScopedThreadRef,
type ThreadLinkedPullRequest,
type VcsStatusResult,
} from "@t3tools/contracts";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { useEffect, useRef } from "react";
import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPanelStore";
import { threadEnvironment } from "../../state/threads";
import { useOrchestrationCommand } from "../../state/use-orchestration-command";
import { useLinkedThreadPullRequest } from "../ThreadStatusIndicators";
import { stackedThreadToast, toastManager } from "../ui/toast";

/**
* The change request a thread is linked to. When the linked one is merged or
* closed and the thread's branch has a newer open one, that one replaces it:
* the thread is relinked on the server, and an open panel showing the old one
* follows.
*/
export function useThreadLinkedPullRequest(input: {
// Null for drafts, which have nothing to relink on the server.
readonly serverThreadRef: ScopedThreadRef | null;
readonly environmentId: EnvironmentId | null;
readonly persisted: ThreadLinkedPullRequest | null;
readonly projectId: ProjectId | null;
readonly projectRepository: string | null;
readonly threadBranch: string | null;
readonly gitStatus: VcsStatusResult | null;
}) {
const { serverThreadRef, persisted, projectId, projectRepository, threadBranch, gitStatus } =
input;
const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
const persistedStatus = useLinkedThreadPullRequest(input.environmentId, persisted);
const detected = gitStatus?.pr;
const replacement: ThreadLinkedPullRequest | null =
persisted === null ||
(persistedStatus?.pr.state !== "merged" && persistedStatus?.pr.state !== "closed") ||
gitStatus?.refName !== threadBranch ||
detected?.state !== "open" ||
detected.headRef !== threadBranch ||
projectId === null ||
projectRepository === null ||
(persisted.projectId === projectId &&
persisted.repository.toLowerCase() === projectRepository.toLowerCase() &&
persisted.number === detected.number)
? null
: { projectId, repository: projectRepository, number: detected.number, url: detected.url };

const relinkKeysRef = useRef(new Map<string, string>());
const relinkWriteRef = useRef(Promise.resolve());
useEffect(() => {
if (serverThreadRef === null) return;
const threadKey = scopedThreadKey(serverThreadRef);
if (replacement === null) {
relinkKeysRef.current.delete(threadKey);
return;
}
const relinkKey = `${replacement.projectId}:${replacement.repository}#${replacement.number}`;
if (relinkKeysRef.current.get(threadKey) === relinkKey) return;
relinkKeysRef.current.set(threadKey, relinkKey);
const openSurface = selectActiveRightPanelSurface(
useRightPanelStore.getState().byThreadKey,
serverThreadRef,
);
if (
openSurface?.kind === "pull-request" &&
persisted !== null &&
openSurface.projectId === persisted.projectId &&
openSurface.repository.toLowerCase() === persisted.repository.toLowerCase() &&
openSurface.number === persisted.number
) {
useRightPanelStore.getState().openPullRequest(serverThreadRef, replacement);
}

relinkWriteRef.current = relinkWriteRef.current.then(async () => {
if (relinkKeysRef.current.get(threadKey) !== relinkKey) return;
const result = await updateThreadMetadata({
environmentId: serverThreadRef.environmentId,
input: { threadId: serverThreadRef.threadId, linkedPullRequest: replacement },
});
if (relinkKeysRef.current.get(threadKey) !== relinkKey) return;
if (result._tag !== "Failure") return;
relinkKeysRef.current.delete(threadKey);
if (isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to update the thread pull request",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
});
}, [persisted, replacement, serverThreadRef, updateThreadMetadata]);

const linked = replacement ?? persisted;
return {
linked,
linkedKey: linked ? JSON.stringify([linked.projectId, linked.repository, linked.number]) : null,
// Detail is shown for the persisted link; a replacement only exists once it is merged.
hasDetail: persisted !== null,
};
}
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export default defineConfig({
"apps/web/src/components/chat/usePendingThreadRequests.ts",
"apps/web/src/components/chat/useQueuedRunEdit.ts",
"apps/web/src/components/chat/useUsageLimitsPanel.ts",
"apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts",
"apps/web/src/components/terminal/**",
],
rules: {
Expand Down
Loading