diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 43c970053e77..54960558aaa6 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,7 +10,13 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import type { RightPanelSurface } from "../rightPanelStore"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { + selectActiveRightPanel, + selectThreadRightPanelState, + useRightPanelStore, + type RightPanelSurface, +} from "../rightPanelStore"; import { CommandId, EnvironmentId, @@ -20,6 +26,7 @@ import { ProviderInstanceId, ThreadId, RunId, + type ScopedThreadRef, TurnItemId, type OrchestrationV2ProjectedTurnItem, type WorktreeSetupSnapshot, @@ -27,7 +34,7 @@ import { import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -44,6 +51,7 @@ import { resolveComposerProviderSelection, resolveProactiveTurnDiffAction, resolveDraftHeroState, + resolveExplicitDiffOpenOnActivate, resolveWorktreeSetupProgress, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, @@ -1805,6 +1813,106 @@ describe("proactive completed diff guard", () => { }); }); +describe("Diff scope across right-panel tab switches", () => { + const threadRef: ScopedThreadRef = { environmentId, threadId }; + const otherThreadRef: ScopedThreadRef = { environmentId, threadId: ThreadId.make("thread-2") }; + let explicitDiffOpen: ScopedThreadRef | null = null; + + const diffIsActive = (ref: ScopedThreadRef) => + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref) === "diff"; + const diffScope = (ref: ScopedThreadRef) => + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, ref).kind; + const settle = (ref: ScopedThreadRef) => { + const explicit = explicitDiffOpen; + explicitDiffOpen = null; + if (diffIsActive(ref) && explicit !== ref) { + useDiffPanelStore.getState().selectGitScope(ref, "branch"); + } + }; + const clickTab = (ref: ScopedThreadRef, surfaceId: string) => { + const surface = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + ref, + ).surfaces.find((entry) => entry.id === surfaceId); + explicitDiffOpen = resolveExplicitDiffOpenOnActivate({ + surfaceKind: surface!.kind, + diffOpen: diffIsActive(ref), + activeThreadRef: ref, + }); + useRightPanelStore.getState().activateSurface(ref, surfaceId); + settle(ref); + }; + + beforeEach(() => { + explicitDiffOpen = null; + useRightPanelStore.setState({ + byThreadKey: {}, + userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, + }); + useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); + }); + + it("keeps Uncommitted when the user clicks back to the Diff tab", () => { + const panels = useRightPanelStore.getState(); + panels.open(threadRef, "diff"); + settle(threadRef); + useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged"); + + panels.open(threadRef, "files"); + settle(threadRef); + clickTab(threadRef, "diff"); + + expect(diffScope(threadRef)).toBe("unstaged"); + }); + + it("still opens on Changes for generic openings", () => { + const panels = useRightPanelStore.getState(); + const diffStore = useDiffPanelStore.getState(); + + panels.open(threadRef, "diff"); + settle(threadRef); + expect(diffScope(threadRef)).toBe("branch"); + + diffStore.selectGitScope(threadRef, "unstaged"); + panels.open(threadRef, "files"); + settle(threadRef); + panels.closeSurface(threadRef, "files"); + settle(threadRef); + expect(diffScope(threadRef)).toBe("branch"); + + diffStore.selectGitScope(threadRef, "unstaged"); + panels.open(otherThreadRef, "diff"); + diffStore.selectGitScope(otherThreadRef, "unstaged"); + settle(otherThreadRef); + expect(diffScope(otherThreadRef)).toBe("branch"); + }); + + it("marks only a click that brings the Diff tab on screen", () => { + expect( + resolveExplicitDiffOpenOnActivate({ + surfaceKind: "diff", + diffOpen: false, + activeThreadRef: threadRef, + }), + ).toBe(threadRef); + expect( + resolveExplicitDiffOpenOnActivate({ + surfaceKind: "diff", + diffOpen: true, + activeThreadRef: threadRef, + }), + ).toBeNull(); + expect( + resolveExplicitDiffOpenOnActivate({ + surfaceKind: "files", + diffOpen: false, + activeThreadRef: threadRef, + }), + ).toBeNull(); + }); +}); + describe("shouldRefocusComposerOnWindowFocus", () => { function element( tagName: string, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f01fa3227571..efea4822b1fb 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -202,6 +202,20 @@ export function resolveProactiveTurnDiffAction(input: { return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore"; } +/** + * Clicking back to an existing Diff tab resumes the review in progress, so the + * generic-open reset to Changes must skip it. A diff that is already active is + * left unmarked: the reset never runs for it, and the marker would linger into + * the next generic open. + */ +export function resolveExplicitDiffOpenOnActivate(input: { + readonly surfaceKind: RightPanelSurface["kind"]; + readonly diffOpen: boolean; + readonly activeThreadRef: ScopedThreadRef; +}): ScopedThreadRef | null { + return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d5599f8c46a3..d2991f5fcca0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -17,6 +17,7 @@ import { peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, + resolveExplicitDiffOpenOnActivate, resolveThreadSwitchTimeline, timelineHasEphemeralPreviewUrls, recallCheckoutIsRepo, @@ -5764,6 +5765,11 @@ export default function ChatView(props: ChatViewProps) { const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); const activateRightPanelSurface = (surface: RightPanelSurface) => { if (!activeThreadRef) return; + explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ + surfaceKind: surface.kind, + diffOpen, + activeThreadRef, + }); useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId);