Skip to content
Merged
Show file tree
Hide file tree
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
20 changes: 3 additions & 17 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -115,7 +109,6 @@ const PairRouteWrapper = ({

setSearchParams((prev) => {
prev.set('resumeSessionId', newSession.id);
prev.delete('recipeId');
return prev;
});
} catch (error) {
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}, []);
Expand Down
9 changes: 5 additions & 4 deletions ui/desktop/src/components/recipes/RecipesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Copy link
Collaborator

@lifeizhou-ap lifeizhou-ap Feb 10, 2026

Choose a reason for hiding this comment

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

just curious about why we have to generate the deeplink to pass the recipe around here? I guess you would like to have same interface to start the chat with recipe?

However, I feel it might be better and intuitive to use recipeId as the key to identify the recipe, and with deeplink it would be a bit harder to debug and trace the source of the recipe for the chat

The encodingRecipe will trigger extra server side call which is unnecessary.

I have approved this PR. Please talke a look at the above comments to see if it makes sense before you merge it. Thanks!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point! Changed back to ID and stripped it after loading instead. Also fixed an issue where the bottom menu bar wasn't updating to reflect the loaded extensions.

window.electron.createChatWindow(
undefined,
getInitialWorkingDir(),
undefined,
undefined,
'pair',
recipeId
encodedRecipe
);
trackRecipeStarted(true, undefined, true);
} catch (error) {
Expand Down Expand Up @@ -521,7 +522,7 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleStartRecipeChatInNewWindow(recipeManifestResponse.id);
handleStartRecipeChatInNewWindow(recipe);
}}
variant="outline"
size="sm"
Expand Down
1 change: 1 addition & 0 deletions ui/desktop/src/constants/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 5 additions & 22 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ if (process.platform !== 'darwin') {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
});
Expand Down Expand Up @@ -258,7 +257,6 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
pendingDeepLink = null;
Expand Down Expand Up @@ -297,7 +295,6 @@ app.on('open-url', async (_event, url) => {
undefined,
deeplinkData?.config,
scheduledJobId || undefined,
undefined,
deeplinkData?.parameters
);
windowDeeplinkURL = null;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';
}

Expand All @@ -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()}`;
Expand Down Expand Up @@ -1064,7 +1048,6 @@ const openDirectoryDialog = async (): Promise<OpenDialogReturnValue> => {
undefined,
deeplinkData?.config,
undefined,
undefined,
deeplinkData?.parameters
);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2052,9 +2035,9 @@ async function appMain() {
version,
resumeSessionId,
viewType,
recipeDeeplink,
undefined,
undefined,
recipeId
undefined
);
}
);
Expand Down
6 changes: 3 additions & 3 deletions ui/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,7 +156,7 @@ const electronAPI: ElectronAPI = {
version?: string,
resumeSessionId?: string,
viewType?: string,
recipeId?: string
recipeDeeplink?: string
) =>
ipcRenderer.send(
'create-chat-window',
Expand All @@ -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),
Expand Down
15 changes: 14 additions & 1 deletion ui/desktop/src/recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 7 additions & 10 deletions ui/desktop/src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -36,25 +37,21 @@ export function resumeSession(session: Session, setView: setViewType) {
export async function createSession(
workingDir: string,
options?: {
recipeId?: string;
recipeDeeplink?: string;
extensionConfigs?: ExtensionConfig[];
allExtensions?: FixedExtensionEntry[];
}
): 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) {
Expand All @@ -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;

Expand Down
Loading