Skip to content
Open
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
133 changes: 22 additions & 111 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ export default function ChatView(props: ChatViewProps) {
const setLogicalProjectDraftThreadId = useComposerDraftStore(
(store) => store.setLogicalProjectDraftThreadId,
);
const applyComposerDraftStickyState = useComposerDraftStore((store) => store.applyStickyState);
const draftThread = useComposerDraftStore((store) =>
routeKind === "server"
? store.getDraftSessionByRef(routeThreadRef)
Expand Down Expand Up @@ -3031,131 +3032,41 @@ export default function ChatView(props: ChatViewProps) {
);

const onImplementPlanInNewThread = useCallback(async () => {
const api = readEnvironmentApi(environmentId);
if (
!api ||
!activeThread ||
!activeProject ||
!activeProposedPlan ||
!isServerThread ||
isSendBusy ||
isConnecting ||
sendInFlightRef.current
) {
return;
}

const sendCtx = composerRef.current?.getSendContext();
if (!sendCtx) {
if (!activeThread || !activeProject || !activeProposedPlan) {
return;
}
const {
selectedProvider: ctxSelectedProvider,
selectedModel: ctxSelectedModel,
selectedProviderModels: ctxSelectedProviderModels,
selectedPromptEffort: ctxSelectedPromptEffort,
selectedModelSelection: ctxSelectedModelSelection,
} = sendCtx;

const createdAt = new Date().toISOString();
const nextDraftId = newDraftId();
const nextThreadId = newThreadId();
const planMarkdown = activeProposedPlan.planMarkdown;
const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
const outgoingImplementationPrompt = formatOutgoingPrompt({
provider: ctxSelectedProvider,
model: ctxSelectedModel,
models: ctxSelectedProviderModels,
effort: ctxSelectedPromptEffort,
text: implementationPrompt,
});
const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;
const logicalProjectKey = deriveLogicalProjectKey(activeProject);
const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);

sendInFlightRef.current = true;
beginLocalDispatch({ preparingWorktree: false });
const finish = () => {
sendInFlightRef.current = false;
resetLocalDispatch();
};
setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
threadId: nextThreadId,
createdAt: new Date().toISOString(),
branch: activeThread.branch ?? null,
worktreePath: activeThread.worktreePath ?? null,
runtimeMode,
interactionMode: DEFAULT_INTERACTION_MODE,
});
applyComposerDraftStickyState(nextDraftId);
setComposerDraftPrompt(nextDraftId, implementationPrompt);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing project draft silently discarded on implement

Medium Severity

onImplementPlanInNewThread always creates a fresh nextDraftId and calls setLogicalProjectDraftThreadId without first checking whether a draft already exists for the logical project. setLogicalProjectDraftThreadId replaces the logical-project-to-draft mapping and deletes the previous draft's thread state and composer content if it's no longer referenced. If the user had an in-progress draft with unsaved content for the same project, that content is silently destroyed. The existing openOrReuseProjectDraftThread and useHandleNewThread both check for and reuse existing drafts to avoid this.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ef214d1. Configure here.


await api.orchestration
.dispatchCommand({
type: "thread.create",
commandId: newCommandId(),
threadId: nextThreadId,
projectId: activeProject.id,
title: nextThreadTitle,
modelSelection: nextThreadModelSelection,
runtimeMode,
interactionMode: "default",
branch: activeThread.branch,
worktreePath: activeThread.worktreePath,
createdAt,
})
.then(() => {
return api.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: newCommandId(),
threadId: nextThreadId,
message: {
messageId: newMessageId(),
role: "user",
text: outgoingImplementationPrompt,
attachments: [],
},
modelSelection: ctxSelectedModelSelection,
titleSeed: nextThreadTitle,
runtimeMode,
interactionMode: "default",
sourceProposedPlan: {
threadId: activeThread.id,
planId: activeProposedPlan.id,
},
createdAt,
});
})
.then(() => {
return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId));
})
.then(() => {
// Signal that the plan sidebar should open on the new thread.
planSidebarOpenOnNextThreadRef.current = true;
return navigate({
to: "/$environmentId/$threadId",
params: {
environmentId: activeThread.environmentId,
threadId: nextThreadId,
},
});
})
.catch(async (err: unknown) => {
await api.orchestration
.dispatchCommand({
type: "thread.delete",
commandId: newCommandId(),
threadId: nextThreadId,
})
.catch(() => undefined);
toastManager.add({
type: "error",
title: "Could not start implementation thread",
description:
err instanceof Error ? err.message : "An error occurred while creating the new thread.",
});
})
.then(finish, finish);
await navigate({
to: "/draft/$draftId",
params: buildDraftThreadRouteParams(nextDraftId),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation thread loses plan source linkage

High Severity

The old onImplementPlanInNewThread explicitly sent sourceProposedPlan: { threadId, planId } in the thread.turn.start command, linking the implementation thread to its originating plan. The new flow navigates to a draft thread, where upon submission the regular onSend path runs — but that path's thread.turn.start (around the bootstrap flow) never includes sourceProposedPlan. The onSubmitPlanFollowUp path does include it, but only fires on same-thread plan follow-ups where activeProposedPlan is non-null. On the draft thread, activeProposedPlan is always null since the draft has no proposed plans. This breaks server-side plan-to-implementation tracking and client-side features like the plan sidebar step display.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ef214d1. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point but will increase PR scope, let me know if must be done in this scope.

}, [
activeProject,
activeProposedPlan,
activeThread,
beginLocalDispatch,
isConnecting,
isSendBusy,
isServerThread,
applyComposerDraftStickyState,
navigate,
resetLocalDispatch,
runtimeMode,
environmentId,
setComposerDraftPrompt,
setLogicalProjectDraftThreadId,
]);

const onProviderModelSelect = useCallback(
Expand Down
Loading