Skip to content
Open
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
64 changes: 58 additions & 6 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'react-router-dom';
import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from './sessionLinks';
import { type SharedSessionDetails } from './sharedSessions';
import { setRecipeParametersForSession } from './utils/recipeParametersStore';
import { ErrorUI } from './components/ErrorBoundary';
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
import { toast, ToastContainer } from 'react-toastify';
Expand Down Expand Up @@ -410,7 +411,7 @@ export function AppInner() {
};
}, []);

const { addExtension } = useConfig();
const { addExtension, extensionsList } = useConfig();

useEffect(() => {
try {
Expand All @@ -421,6 +422,52 @@ export function AppInner() {
}
}, []);

useEffect(() => {
const handleOpenRecipeDeeplink = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const payload = args[0] as
| {
recipeDeeplink?: string;
recipeParameters?: Record<string, string>;
scheduledJobId?: string;
}
| undefined;
const recipeDeeplink = payload?.recipeDeeplink;
if (!recipeDeeplink) {
return;
}
try {
const newSession = await createSession(getInitialWorkingDir(), {
recipeDeeplink,
allExtensions: extensionsList,
Comment on lines +439 to +441
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass the recipe config instead of the full URL

When a goose://recipe?... link is opened into an existing window, this passes the full URL to createSession, but createSession forwards recipeDeeplink directly to decodeRecipe, whose backend decoder expects only the base64 recipe config string (the cold-start path still passes deeplinkData?.config). As a result, any recipe/bot deep link handled through this new IPC path fails to decode instead of opening the recipe; extract and pass the config value here, or update the decode path to accept full Goose URLs.

Useful? React with 👍 / 👎.

});
Comment on lines +439 to +442
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Forward deeplink recipe parameters into session creation

This handler receives recipeParameters/scheduledJobId in the IPC payload but only passes recipeDeeplink to createSession, so recipe links opened in an already-running window lose their query-parameter context. That regresses behavior compared with the new-window path (which injects recipeParameters via appConfig) and means parameterized recipe links open without prefilled values in the parameter modal.

Useful? React with 👍 / 👎.

setRecipeParametersForSession(newSession.id, payload?.recipeParameters);
window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: {
sessionId: newSession.id,
initialMessage: newSession.recipe?.prompt
? { msg: newSession.recipe.prompt, images: [] }
: undefined,
},
})
);
navigate(`/pair?resumeSessionId=${encodeURIComponent(newSession.id)}`);
} catch (error) {
console.error('Failed to open recipe deeplink in existing window:', error);
trackErrorWithContext(error, {
component: 'AppInner',
action: 'open_recipe_deeplink',
recoverable: true,
});
toast.error(`Failed to open recipe: ${errorMessage(error, 'Unknown error')}`);
}
};
window.electron.on('open-recipe-deeplink', handleOpenRecipeDeeplink);
return () => {
window.electron.off('open-recipe-deeplink', handleOpenRecipeDeeplink);
};
}, [navigate, extensionsList]);

useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const link = args[0] as string;
Expand Down Expand Up @@ -486,13 +533,18 @@ export function AppInner() {
// Show a toast if mesh is the configured provider but isn't running.
useEffect(() => {
const handler = () => {
toast.warn('Inference Mesh is set as your provider but isn\'t running. Open Settings → Mesh to start it. Keep goose running to stay connected.', {
autoClose: false,
toastId: 'mesh-not-running',
});
toast.warn(
"Inference Mesh is set as your provider but isn't running. Open Settings → Mesh to start it. Keep goose running to stay connected.",
{
autoClose: false,
toastId: 'mesh-not-running',
}
);
};
window.electron.on('mesh-not-running', handler);
return () => { window.electron.off('mesh-not-running', handler); };
return () => {
window.electron.off('mesh-not-running', handler);
};
}, []);

// Prevent default drag and drop behavior globally to avoid opening files in new windows
Expand Down
2 changes: 2 additions & 0 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage, getTextAndImageContent } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
import { substituteParameters } from '../utils/parameterSubstitution';
import { takeRecipeParametersForSession } from '../utils/recipeParametersStore';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
import { Recipe } from '../recipe';
Expand Down Expand Up @@ -542,6 +543,7 @@ export default function BaseChat({
onSubmit={setRecipeUserParams}
onClose={() => setView('chat')}
initialValues={
takeRecipeParametersForSession(chat.sessionId) ||
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep deeplink parameters stable while the modal is open

When a parameterized recipe link is opened into an existing window, this consumes and deletes the stored values during BaseChat render. On the next parent re-render while ParameterInputModal is still mounted, initialValues becomes undefined; because the modal's prefill effect depends on initialValues, it resets the form back to defaults and drops the URL-supplied values (and possibly user edits). The values should be read once into stable state/ref for the session instead of calling the destructive take... in the render path.

Useful? React with 👍 / 👎.

(window.appConfig?.get('recipeParameters') as Record<string, string> | undefined) ||
undefined
}
Expand Down
104 changes: 66 additions & 38 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,16 +396,7 @@ if (process.platform !== 'darwin') {
app.whenReady().then(async () => {
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;

const deeplinkData = parseRecipeDeeplink(protocolUrl);
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');

await createChat(app, {
dir: openDir || undefined,
recipeDeeplink: deeplinkData?.config,
scheduledJobId: scheduledJobId || undefined,
recipeParameters: deeplinkData?.parameters,
});
await openRecipeDeeplink(protocolUrl, openDir);
});
return; // Skip the rest of the handler
}
Expand Down Expand Up @@ -486,34 +477,73 @@ async function handleProtocolUrl(url: string) {
}
}

async function processProtocolUrl(url: string, parsedUrl: URL, window: BrowserWindow) {
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
let windowDeeplinkURL: string | null = null;

if (parsedUrl.hostname === 'extension') {
window.webContents.send('add-extension', url);
} else if (parsedUrl.hostname === 'sessions') {
window.webContents.send('open-shared-session', url);
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
const deeplinkData = parseRecipeDeeplink(url);
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
async function openRecipeDeeplink(url: string, openDir: string | null) {
const deeplinkData = parseRecipeDeeplink(url);
const parsedUrl = new URL(url);
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');

const existingWindows = BrowserWindow.getAllWindows();
const targetWindow =
BrowserWindow.getFocusedWindow() ??
existingWindows[existingWindows.length - 1] ??
existingWindows[0] ??
null;

if (targetWindow && !targetWindow.isDestroyed() && targetWindow.webContents) {
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.focus();
if (targetWindow.webContents.isLoadingMainFrame()) {
pendingDeepLinks.set(targetWindow.id, url);
} else {
targetWindow.webContents.send('open-recipe-deeplink', {
recipeDeeplink: deeplinkData?.config,
recipeParameters: deeplinkData?.parameters,
scheduledJobId: scheduledJobId || undefined,
});
}
return;
}

if (deeplinkData) {
windowDeeplinkURL = url;
}
try {
await createChat(app, {
dir: openDir || undefined,
recipeDeeplink: deeplinkData?.config,
scheduledJobId: scheduledJobId || undefined,
recipeParameters: deeplinkData?.parameters,
});
} finally {
windowDeeplinkURL = null;
}
}

let windowDeeplinkURL: string | null = null;
async function processProtocolUrl(url: string, parsedUrl: URL, window: BrowserWindow) {
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;

if (parsedUrl.hostname === 'extension') {
window.webContents.send('add-extension', url);
} else if (parsedUrl.hostname === 'sessions') {
window.webContents.send('open-shared-session', url);
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
await openRecipeDeeplink(url, openDir);
}
}

app.on('open-url', async (_event, url) => {
if (process.platform !== 'win32') {
const parsedUrl = new URL(url);

log.info('[Main] Received open-url event:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
log.info(
'[Main] Received open-url event:',
url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url
);

await app.whenReady();

Expand All @@ -528,23 +558,11 @@ app.on('open-url', async (_event, url) => {
return;
}

// Handle bot/recipe URLs by directly creating a new window
// Handle bot/recipe URLs by reusing an existing window when available
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
log.info('[Main] Detected bot/recipe URL, creating new chat window');
log.info('[Main] Detected bot/recipe URL');
openUrlHandledLaunch = true;
const deeplinkData = parseRecipeDeeplink(url);
if (deeplinkData) {
windowDeeplinkURL = url;
}
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');

await createChat(app, {
dir: openDir || undefined,
recipeDeeplink: deeplinkData?.config,
scheduledJobId: scheduledJobId || undefined,
recipeParameters: deeplinkData?.parameters,
});
windowDeeplinkURL = null;
await openRecipeDeeplink(url, openDir);
return;
}

Expand Down Expand Up @@ -893,7 +911,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
const stderrTail = diagnostics?.stderrTail ?? [];
const failureDetailParts = [
diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${
diagnostics?.childExitSignal ?? 'null'
}`
: 'Child exit: unavailable',
diagnostics?.certFingerprintSeen
? 'TLS fingerprint observed: yes'
Expand Down Expand Up @@ -1520,6 +1540,14 @@ ipcMain.on('react-ready', (event) => {
window.webContents.send('add-extension', deepLinkUrl);
} else if (parsedUrl.hostname === 'sessions') {
window.webContents.send('open-shared-session', deepLinkUrl);
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
const deeplinkData = parseRecipeDeeplink(deepLinkUrl);
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
window.webContents.send('open-recipe-deeplink', {
recipeDeeplink: deeplinkData?.config,
recipeParameters: deeplinkData?.parameters,
scheduledJobId: scheduledJobId || undefined,
});
}
} catch (error) {
log.error('Error processing pending deep link:', error);
Expand Down
25 changes: 25 additions & 0 deletions ui/desktop/src/utils/recipeParametersStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Bridges URL-supplied recipe parameters from the warm-launch IPC path into
// ParameterInputModal. The cold-launch path seeds the same data into
// window.appConfig.recipeParameters, which is fixed at preload and cannot be
// mutated after window load.

const store = new Map<string, Record<string, string>>();

export function setRecipeParametersForSession(
sessionId: string,
parameters: Record<string, string> | undefined
): void {
if (parameters && Object.keys(parameters).length > 0) {
store.set(sessionId, parameters);
}
}

export function takeRecipeParametersForSession(
sessionId: string
): Record<string, string> | undefined {
const value = store.get(sessionId);
if (value) {
store.delete(sessionId);
}
return value;
}