From fb93902ee24d4ba380508df33d25478d3baf7a72 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 18:51:21 -0400 Subject: [PATCH] feat(web): add proactive panels (#9276) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + .../web/src/components/ChatView.logic.test.ts | 47 +++++++ apps/web/src/components/ChatView.logic.ts | 22 +++ apps/web/src/components/ChatView.tsx | 127 +++++++++++++++++- .../components/settings/SettingsPanels.tsx | 31 +++++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/source-control.md | 2 + packages/contracts/src/settings.test.ts | 9 ++ packages/contracts/src/settings.ts | 2 + 9 files changed, 244 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c02a45026bc777612a9e5c9529bc011b45280f0..0bddf23e46e6ed0f3587f8f2fd77ceb7c163cd99 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -43,6 +43,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, panelAnimationDurationMs: 0, planModeEnabled: false, + proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 290e3435abca60c3cf5c163e55bbc485c0b9192f..9fe03c1c980f37ae84de7c27fd9ab928588e693f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -40,6 +40,8 @@ import { codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, + shouldOpenProactivePullRequest, + shouldOpenProactiveTurnDiff, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -83,6 +85,51 @@ describe("agent browser close confirmation", () => { }); }); +describe("proactive panels", () => { + it("opens a pull request only after a newly observed link appears", () => { + expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); + expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); + expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); + expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); + }); + + it("opens the diff only when the observed running turn settles", () => { + const turnId = TurnId.make("turn-1"); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: undefined, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(false); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(true); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: TurnId.make("turn-2"), + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(false); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: false, + }), + ).toBe(false); + }); +}); + describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e9a18d016877b808557e4ccef3d0bcb7b3f7d15e..0873787063387c4834b53722dec197082ef35236 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -78,6 +78,28 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldOpenProactivePullRequest( + previousTargetKey: string | null | undefined, + targetKey: string | null, +): boolean { + return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; +} + +export function shouldOpenProactiveTurnDiff(input: { + previousRunningTurnId: TurnId | null | undefined; + runningTurnId: TurnId | null; + settledTurnId: TurnId | null; + turnCompleted: boolean; +}): boolean { + return ( + input.previousRunningTurnId !== undefined && + input.previousRunningTurnId !== null && + input.runningTurnId === null && + input.turnCompleted && + input.settledTurnId === input.previousRunningTurnId + ); +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6afaedad5bf2c7f2f2c0ce65ccb4e05195349ef2..ad43381c645e976c10b412c33aaec8433adee2e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -361,6 +361,8 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, + shouldOpenProactivePullRequest, + shouldOpenProactiveTurnDiff, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1766,6 +1768,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -3665,7 +3668,16 @@ function ChatViewContent(props: ChatViewProps) { ); // 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 linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; + const linkedThreadPullRequest = isServerThread + ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) + : (activeThread?.linkedPullRequest ?? null); + const linkedThreadPullRequestKey = linkedThreadPullRequest + ? JSON.stringify([ + linkedThreadPullRequest.projectId, + linkedThreadPullRequest.repository, + linkedThreadPullRequest.number, + ]) + : null; const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( @@ -3708,6 +3720,117 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); + const proactiveTurnObservationRef = useRef<{ + threadKey: string; + runningTurnId: TurnId | null; + } | null>(null); + const proactivePullRequestObservationRef = useRef<{ + threadKey: string; + targetKey: string | null; + } | null>(null); + + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + proactiveTurnObservationRef.current = null; + return; + } + if (!clientSettingsHydrated || threadDetailLoading) { + return; + } + + const previousObservation = proactiveTurnObservationRef.current; + const observingSameThread = previousObservation?.threadKey === activeThreadKey; + const previousRunningTurnId = observingSameThread + ? previousObservation.runningTurnId + : undefined; + const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null; + const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({ + previousRunningTurnId, + runningTurnId: activeRunningTurnId, + settledTurnId, + turnCompleted: activeLatestTurn?.state === "completed", + }) + ? settledTurnId + : null; + const eligibleCompletion = + settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null; + const checkpointReady = + eligibleCompletion && + activeThread?.checkpoints.some((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) === + true; + const shouldOpenTurn = checkpointReady && gitStatusQuery.data?.isRepo === true; + const shouldDeferCompletion = + eligibleCompletion && !shouldOpenTurn && gitStatusQuery.data?.isRepo !== false; + proactiveTurnObservationRef.current = { + threadKey: activeThreadKey, + runningTurnId: shouldDeferCompletion ? (previousRunningTurnId ?? null) : activeRunningTurnId, + }; + if (!shouldOpenTurn || newlyCompletedTurnId === null) return; + + useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }, [ + activeThread?.checkpoints, + activeLatestTurn?.turnId, + activeLatestTurn?.state, + activeRunningTurnId, + activeThreadKey, + activeThreadRef, + clientSettingsHydrated, + gitStatusQuery.data?.isRepo, + isServerThread, + latestTurnSettled, + onDiffPanelOpen, + settings.proactivePanelsEnabled, + shouldUseRightPanelSheet, + threadDetailLoading, + ]); + + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + proactivePullRequestObservationRef.current = null; + return; + } + if (!clientSettingsHydrated || threadDetailLoading) { + return; + } + + const previousObservation = proactivePullRequestObservationRef.current; + const observingSameThread = previousObservation?.threadKey === activeThreadKey; + const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined; + const newlyLinkedPullRequest = shouldOpenProactivePullRequest( + previousTargetKey, + linkedThreadPullRequestKey, + ); + const eligibleLink = + settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest; + const shouldOpenLink = + eligibleLink && + pullRequestsCapabilityKnown && + supportsPullRequests && + linkedThreadPullRequest !== null; + const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; + proactivePullRequestObservationRef.current = { + threadKey: activeThreadKey, + targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, + }; + if (!shouldOpenLink || linkedThreadPullRequest === null) return; + + useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); + }, [ + activeThreadKey, + activeThreadRef, + clientSettingsHydrated, + isServerThread, + linkedThreadPullRequest, + linkedThreadPullRequestKey, + pullRequestsCapabilityKnown, + settings.proactivePanelsEnabled, + shouldUseRightPanelSheet, + supportsPullRequests, + threadDetailLoading, + ]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4612,8 +4735,6 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // The server-projected settled state keeps the banner and sidebar in sync. - const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { return null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 51423cf996e606f1d4d98751424d868525b2a2f5..2fdc985a84d8e6ac74523f0360265d41a989b08e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled + ? ["Proactive panels"] + : []), ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), @@ -588,6 +591,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.proactivePanelsEnabled, settings.environmentIdentificationMode, settings.contextWindowMeterEnabled, settings.fontFamilyCode, @@ -683,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, @@ -2206,6 +2211,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ proactivePanelsEnabled: Boolean(checked) }) + } + aria-label="Proactive panels" + /> + } + /> + { }); }); +describe("ClientSettings proactive panels", () => { + it("is opt-in and accepts client-local updates", () => { + expect(decodeClientSettings({}).proactivePanelsEnabled).toBe(false); + expect(decodeClientSettingsPatch({ proactivePanelsEnabled: true }).proactivePanelsEnabled).toBe( + true, + ); + }); +}); + describe("ClientSettings quit confirmation", () => { it("defaults to hold", () => { expect(decodeClientSettings({}).confirmQuit).toBe("hold"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9eeabba67b7467e7bb39ccf03a321a6ee372d06c..8de2b73e2ad97a551f14031c93944eaea1a4eac3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -299,6 +299,7 @@ export const ClientSettingsSchema = Schema.Struct({ // Legacy context window meter. The composer hides it by default; users who // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the @@ -1029,6 +1030,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), + proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), -- 2.54.0