fix: strip empty extensions array when deeplink also (#7096)

This commit is contained in:
Zane
2026-02-10 08:47:19 -08:00
committed by GitHub
parent 0d5db02a02
commit 7267621ab8
8 changed files with 44 additions and 57 deletions
+3 -17
View File
@@ -83,23 +83,17 @@ const PairRouteWrapper = ({
const [isCreatingSession, setIsCreatingSession] = useState(false);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
const initialMessage = routeState.initialMessage;
// Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId
// Create session if we have an initialMessage or recipeDeeplink but no sessionId
useEffect(() => {
if (
(initialMessage || recipeId || recipeDeeplinkFromConfig) &&
!resumeSessionId &&
!isCreatingSession
) {
if ((initialMessage || recipeDeeplinkFromConfig) && !resumeSessionId && !isCreatingSession) {
setIsCreatingSession(true);
(async () => {
try {
const newSession = await createSession(getInitialWorkingDir(), {
recipeId,
recipeDeeplink: recipeDeeplinkFromConfig,
allExtensions: extensionsList,
});
@@ -115,7 +109,6 @@ const PairRouteWrapper = ({
setSearchParams((prev) => {
prev.set('resumeSessionId', newSession.id);
prev.delete('recipeId');
return prev;
});
} catch (error) {
@@ -133,14 +126,7 @@ const PairRouteWrapper = ({
// Note: isCreatingSession is intentionally NOT in the dependency array
// It's only used as a guard to prevent concurrent session creation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialMessage,
recipeId,
recipeDeeplinkFromConfig,
resumeSessionId,
setSearchParams,
extensionsList,
]);
}, [initialMessage, recipeDeeplinkFromConfig, resumeSessionId, setSearchParams, extensionsList]);
// Add resumed session to active sessions if not already there
useEffect(() => {
@@ -40,11 +40,17 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}, 500);
};
const handleExtensionsLoaded = () => {
setRefreshTrigger((prev) => prev + 1);
};
window.addEventListener(AppEvents.SESSION_CREATED, handleSessionLoaded);
window.addEventListener(AppEvents.SESSION_EXTENSIONS_LOADED, handleExtensionsLoaded);
window.addEventListener(AppEvents.MESSAGE_STREAM_FINISHED, handleSessionLoaded);
return () => {
window.removeEventListener(AppEvents.SESSION_CREATED, handleSessionLoaded);
window.removeEventListener(AppEvents.SESSION_EXTENSIONS_LOADED, handleExtensionsLoaded);
window.removeEventListener(AppEvents.MESSAGE_STREAM_FINISHED, handleSessionLoaded);
};
}, []);
@@ -32,7 +32,7 @@ import {
} from '../../api';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
import CreateEditRecipeModal from './CreateEditRecipeModal';
import { generateDeepLink, Recipe, stripEmptyExtensions } from '../../recipe';
import { generateDeepLink, encodeRecipe, Recipe, stripEmptyExtensions } from '../../recipe';
import { useNavigation } from '../../hooks/useNavigation';
import { CronPicker } from '../schedule/CronPicker';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
@@ -165,15 +165,16 @@ export default function RecipesView() {
}
};
const handleStartRecipeChatInNewWindow = (recipeId: string) => {
const handleStartRecipeChatInNewWindow = async (recipe: Recipe) => {
try {
const encodedRecipe = await encodeRecipe(stripEmptyExtensions(recipe) as Recipe);
window.electron.createChatWindow(
undefined,
getInitialWorkingDir(),
undefined,
undefined,
'pair',
recipeId
encodedRecipe
);
trackRecipeStarted(true, undefined, true);
} catch (error) {
@@ -521,7 +522,7 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleStartRecipeChatInNewWindow(recipeManifestResponse.id);
handleStartRecipeChatInNewWindow(recipe);
}}
variant="outline"
size="sm"
+1
View File
@@ -3,6 +3,7 @@
*/
export enum AppEvents {
SESSION_CREATED = 'session-created',
SESSION_EXTENSIONS_LOADED = 'session-extensions-loaded',
SESSION_DELETED = 'session-deleted',
SESSION_RENAMED = 'session-renamed',
SESSION_FORKED = 'session-forked',
+5 -22
View File
@@ -157,7 +157,6 @@ if (process.platform !== 'darwin') {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
});
@@ -258,7 +257,6 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
pendingDeepLink = null;
@@ -297,7 +295,6 @@ app.on('open-url', async (_event, url) => {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
windowDeeplinkURL = null;
@@ -480,7 +477,6 @@ const createChat = async (
viewType?: string,
recipeDeeplink?: string, // Raw deeplink decoded on server
scheduledJobId?: string, // Scheduled job ID if applicable
recipeId?: string,
recipeParameters?: Record<string, string> // Recipe parameter values from deeplink URL
) => {
const settings = getSettings();
@@ -528,7 +524,6 @@ const createChat = async (
REQUEST_DIR: dir,
GOOSE_BASE_URL_SHARE: baseUrlShare,
GOOSE_VERSION: version,
recipeId: recipeId,
recipeDeeplink: recipeDeeplink,
recipeParameters: recipeParameters,
scheduledJobId: scheduledJobId,
@@ -711,10 +706,7 @@ const createChat = async (
if (viewType) {
appPath = routeMap[viewType] || '/';
}
if (
appPath === '/' &&
(recipeDeeplink !== undefined || recipeId !== undefined || initialMessage)
) {
if (appPath === '/' && (recipeDeeplink !== undefined || initialMessage)) {
appPath = '/pair';
}
@@ -725,14 +717,6 @@ const createChat = async (
appPath = '/pair';
}
}
// Only add recipeId to URL for the non-deeplink case (saved recipes launched from UI)
// For deeplinks, the recipe object is passed via appConfig, not URL params
if (recipeId) {
searchParams.set('recipeId', recipeId);
if (appPath === '/') {
appPath = '/pair';
}
}
// Goose's react app uses HashRouter, so the path + search params follow a #/
url.hash = `${appPath}?${searchParams.toString()}`;
@@ -1064,7 +1048,6 @@ const openDirectoryDialog = async (): Promise<OpenDialogReturnValue> => {
undefined,
deeplinkData?.config,
undefined,
undefined,
deeplinkData?.parameters
);
}
@@ -2018,13 +2001,13 @@ async function appMain() {
ipcMain.on(
'create-chat-window',
(event, query, dir, version, resumeSessionId, viewType, recipeId) => {
(event, query, dir, version, resumeSessionId, viewType, recipeDeeplink) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
}
const isFromLauncher = query && !resumeSessionId && !viewType && !recipeId;
const isFromLauncher = query && !resumeSessionId && !viewType && !recipeDeeplink;
if (isFromLauncher) {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
@@ -2052,9 +2035,9 @@ async function appMain() {
version,
resumeSessionId,
viewType,
recipeDeeplink,
undefined,
undefined,
recipeId
undefined
);
}
);
+3 -3
View File
@@ -64,7 +64,7 @@ type ElectronAPI = {
version?: string,
resumeSessionId?: string,
viewType?: string,
recipeId?: string
recipeDeeplink?: string
) => void;
logInfo: (txt: string) => void;
showNotification: (data: NotificationData) => void;
@@ -156,7 +156,7 @@ const electronAPI: ElectronAPI = {
version?: string,
resumeSessionId?: string,
viewType?: string,
recipeId?: string
recipeDeeplink?: string
) =>
ipcRenderer.send(
'create-chat-window',
@@ -165,7 +165,7 @@ const electronAPI: ElectronAPI = {
version,
resumeSessionId,
viewType,
recipeId
recipeDeeplink
),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
+14 -1
View File
@@ -49,7 +49,7 @@ export async function decodeRecipe(deeplink: string): Promise<Recipe> {
throw new Error('Decoded recipe is null');
}
return response.data.recipe as Recipe;
return stripEmptyExtensions(response.data.recipe as Recipe);
} catch (error) {
console.error('Failed to decode deeplink:', error);
throw error;
@@ -78,6 +78,19 @@ export async function generateDeepLink(recipe: Recipe): Promise<string> {
return `goose://recipe?config=${encoded}`;
}
/**
* Strips empty extensions arrays from recipes before passing to the backend.
*
* This is a backwards compatibility workaround for the desktop app. Previously,
* the UI was saving recipes with an empty `extensions: []` array, which the
* backend interprets as "use no extensions" rather than "use user's default
* extensions". By removing the empty array, the backend will fall back to
* loading the user's configured default extensions.
*
* This can be removed once we have the ability to manage recipe extensions
* directly in the UI, allowing users to explicitly choose which extensions
* a recipe should use.
*/
export function stripEmptyExtensions(recipe: Recipe): Recipe {
if (Array.isArray(recipe.extensions) && recipe.extensions.length === 0) {
const { extensions: _, ...rest } = recipe;
+7 -10
View File
@@ -7,6 +7,7 @@ import {
} from './store/extensionOverrides';
import type { FixedExtensionEntry } from './components/ConfigContext';
import { AppEvents } from './constants/events';
import { decodeRecipe, Recipe } from './recipe';
export function shouldShowNewChatTitle(session: Session): boolean {
if (session.recipe) {
@@ -36,7 +37,6 @@ export function resumeSession(session: Session, setView: setViewType) {
export async function createSession(
workingDir: string,
options?: {
recipeId?: string;
recipeDeeplink?: string;
extensionConfigs?: ExtensionConfig[];
allExtensions?: FixedExtensionEntry[];
@@ -44,17 +44,14 @@ export async function createSession(
): Promise<Session> {
const body: {
working_dir: string;
recipe_id?: string;
recipe_deeplink?: string;
recipe?: Recipe;
extension_overrides?: ExtensionConfig[];
} = {
working_dir: workingDir,
};
if (options?.recipeId) {
body.recipe_id = options.recipeId;
} else if (options?.recipeDeeplink) {
body.recipe_deeplink = options.recipeDeeplink;
if (options?.recipeDeeplink) {
body.recipe = await decodeRecipe(options.recipeDeeplink);
}
if (options?.extensionConfigs && options.extensionConfigs.length > 0) {
@@ -81,15 +78,15 @@ export async function startNewSession(
setView: setViewType,
workingDir: string,
options?: {
recipeId?: string;
recipeDeeplink?: string;
allExtensions?: FixedExtensionEntry[];
}
): Promise<Session> {
const session = await createSession(workingDir, options);
// Include session data so sidebar can add it immediately (before it has messages)
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED, { detail: { session } }));
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { session } })
);
const initialMessage = initialText ? { msg: initialText, images: [] } : undefined;