From 743d2d0c1134f3fda5012adae50694ac91875d2c Mon Sep 17 00:00:00 2001 From: Enis Date: Thu, 20 Aug 2026 11:28:42 +0200 Subject: [PATCH 1/2] feat(diff): show worktree changes for a draft before sending Read a not-yet-sent composer draft's worktree/environment/project so the shared diff, git status, and branch preview load when the panel opens, instead of only after the first message promotes the draft to a server thread. Drafts with no worktree keep the empty per-chat panel, matching the pre-draft behavior. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/DiffPanel.tsx | 61 ++++++++++++++++++--------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 26da5e6e5146..38e3ca49b5aa 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -20,7 +20,7 @@ import { } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { type DraftId } from "../composerDraftStore"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; @@ -309,27 +309,48 @@ export default function DiffPanel({ // thread yet, so this resolves to the representative sibling that owns the // worktree; for an ordinary chat it's the same worktree either way. const workspaceThread = useThread(workspaceThreadRef) ?? activeThread; - const activeProjectId = workspaceThread?.projectId ?? null; + // A worktree can hold only a not-yet-sent draft (no server thread), so + // `workspaceThread` is null even though the draft already targets a worktree + // on disk. Read that draft's worktree/environment/project so the shared diff, + // git status, and branch preview load on open instead of only after the first + // message promotes the draft to a server thread. Drafts with no worktree keep + // their empty per-chat panel, matching the pre-draft behavior. + const draftThreadsByThreadKey = useComposerDraftStore((state) => state.draftThreadsByThreadKey); + const workspaceDraft = useMemo(() => { + if (workspaceThread || !workspaceThreadRef) return null; + const draft = Object.values(draftThreadsByThreadKey).find( + (candidate) => + candidate.environmentId === workspaceThreadRef.environmentId && + candidate.threadId === workspaceThreadRef.threadId, + ); + return draft?.worktreePath != null ? draft : null; + }, [draftThreadsByThreadKey, workspaceThread, workspaceThreadRef]); + // The resolved worktree data source: a real thread when one exists, otherwise + // the worktree-targeting draft. Everything below keys off these instead of + // `workspaceThread` so a draft-only worktree behaves like a real one. + const workspaceEnvironmentId = + workspaceThread?.environmentId ?? workspaceDraft?.environmentId ?? null; + const hasWorkspaceSource = workspaceThread != null || workspaceDraft != null; + const activeProjectId = workspaceThread?.projectId ?? workspaceDraft?.projectId ?? null; const activeProject = useProject( - workspaceThread && activeProjectId + workspaceEnvironmentId && activeProjectId ? { - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, projectId: activeProjectId, } : null, ); - const activeCwd = workspaceThread?.worktreePath ?? activeProject?.workspaceRoot; - const serverConfig = useAtomValue( - serverEnvironment.configValueAtom(workspaceThread?.environmentId ?? null), - ); + const activeCwd = + workspaceThread?.worktreePath ?? workspaceDraft?.worktreePath ?? activeProject?.workspaceRoot; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(workspaceEnvironmentId)); const openInPreferredEditor = useOpenInPreferredEditor( - workspaceThread?.environmentId ?? null, + workspaceEnvironmentId, serverConfig?.availableEditors ?? [], ); const gitStatusQuery = useEnvironmentQuery( - workspaceThread != null && activeCwd != null + workspaceEnvironmentId != null && activeCwd != null ? vcsEnvironment.status({ - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: activeCwd }, }) : null, @@ -442,9 +463,9 @@ export default function DiffPanel({ { enabled: isGitRepo && selectedTurn !== undefined }, ); const primaryBranchDiffPreview = useEnvironmentQuery( - selectedTurnId === null && workspaceThread && activeCwd + selectedTurnId === null && workspaceEnvironmentId && activeCwd ? reviewEnvironment.diffPreview({ - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: activeCwd, ...(effectiveBaseRef ? { baseRef: effectiveBaseRef } : {}), @@ -465,9 +486,9 @@ export default function DiffPanel({ serverConfig?.cwd !== undefined && serverConfig.cwd !== activeCwd; const fallbackBranchDiffPreview = useEnvironmentQuery( - shouldRetryBranchDiffAtEnvironmentCwd && workspaceThread && serverConfig + shouldRetryBranchDiffAtEnvironmentCwd && workspaceEnvironmentId && serverConfig ? reviewEnvironment.diffPreview({ - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: serverConfig.cwd, ...(effectiveBaseRef ? { baseRef: effectiveBaseRef } : {}), @@ -537,10 +558,10 @@ export default function DiffPanel({ const localBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && - workspaceThread && + workspaceEnvironmentId && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: branchDiffPreview.data.cwd, includeMatchingRemoteRefs: true, @@ -554,10 +575,10 @@ export default function DiffPanel({ const remoteBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && - workspaceThread && + workspaceEnvironmentId && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ - environmentId: workspaceThread.environmentId, + environmentId: workspaceEnvironmentId, input: { cwd: branchDiffPreview.data.cwd, includeMatchingRemoteRefs: true, @@ -1191,7 +1212,7 @@ export default function DiffPanel({ return ( - {!workspaceThread ? ( + {!hasWorkspaceSource ? (
Select a thread to inspect turn diffs.
From aeb0691202c87b95b0685ff858e1ea9f255fbc9f Mon Sep 17 00:00:00 2001 From: Enis Date: Thu, 20 Aug 2026 11:47:32 +0200 Subject: [PATCH 2/2] fix(review): review the worktree's live branch, not the stored ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Review button (and "new chat in this worktree") targeted the thread's persisted `branch` field, which drifts from the worktree's actually checked-out branch after a checkout, PR checkout, or branch switch. The footer branch chip already prefers the live git ref, so the two diverged and a review could run against a stale reference. Resolve the new-chat branch the same way the footer does — live `gitStatus.refName`, falling back to the stored branch — for worktree-backed chats, so a review always follows the branch shown in the chat. Local chats keep the stored value. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 31 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4e98ec80733..1078bf29c6aa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1658,7 +1658,6 @@ function ChatViewContent(props: ChatViewProps) { // multiple chats share one on-disk tree, matching the sidebar's // "New thread on {branch}" affordance. const newChatWorktreePath = activeThread?.worktreePath ?? null; - const newChatWorktreeBranch = activeThread?.branch ?? null; // File-diff "content tabs" live in the chat-column tab strip and are scoped to // the worktree, so they stay visible while switching between its chats. Key // them off the stable route ref (resolved via the shell/draft worktree) rather @@ -1674,16 +1673,6 @@ function ChatViewContent(props: ChatViewProps) { // The browser preview is shown as a content tab in the chat column (beside the // file/diff viewers), not in the right panel. const previewTabActive = activeContentTab?.view === "preview"; - const handleNewChatInScope = useCallback(() => { - if (!activeProjectRef) return; - void handleNewThread(activeProjectRef, { - branch: newChatWorktreeBranch, - worktreePath: newChatWorktreePath, - envMode: newChatWorktreePath ? "worktree" : "local", - forceNew: true, - preservePreviousDraft: true, - }); - }, [activeProjectRef, newChatWorktreeBranch, newChatWorktreePath, handleNewThread]); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -2509,6 +2498,26 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); + // The reference a review or new worktree chat targets must follow the + // worktree's *actual* checked-out branch — the one shown in the footer chip — + // not the thread's stored `branch`, which drifts after a checkout, PR + // checkout, or branch switch inside the worktree. Mirror the footer's + // resolution (live git ref, then stored branch) for worktree-backed chats so + // a review never runs against a stale reference; local chats keep the stored + // value. + const newChatWorktreeBranch = newChatWorktreePath + ? (gitStatusQuery.data?.refName ?? activeThread?.branch ?? null) + : (activeThread?.branch ?? null); + const handleNewChatInScope = useCallback(() => { + if (!activeProjectRef) return; + void handleNewThread(activeProjectRef, { + branch: newChatWorktreeBranch, + worktreePath: newChatWorktreePath, + envMode: newChatWorktreePath ? "worktree" : "local", + forceNew: true, + preservePreviousDraft: true, + }); + }, [activeProjectRef, newChatWorktreeBranch, newChatWorktreePath, handleNewThread]); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g.