fix(desktop): start recipe deeplink sessions from the recipe prompt (#8424)

Signed-off-by: sunilkumarvalmiki <g.sunilkumarvalmiki@gmail.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
g.sunilkumar
2026-04-10 11:46:23 +05:30
committed by GitHub
parent ed4836b923
commit b1eff5f7f9
5 changed files with 140 additions and 6 deletions
+23 -2
View File
@@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { screen, render, waitFor } from '@testing-library/react'; import { screen, render, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { AppInner } from './App'; import { AppInner, resolveSessionInitialMessage } from './App';
import { IntlTestWrapper } from './i18n/test-utils'; import { IntlTestWrapper } from './i18n/test-utils';
// Set up globals for jsdom // Set up globals for jsdom
@@ -60,6 +60,7 @@ vi.mock('./sessions', () => ({
.fn() .fn()
.mockResolvedValue({ sessionId: 'test', messages: [], metadata: { description: '' } }), .mockResolvedValue({ sessionId: 'test', messages: [], metadata: { description: '' } }),
generateSessionId: vi.fn(), generateSessionId: vi.fn(),
createSession: vi.fn(),
})); }));
// Mock the ConfigContext module // Mock the ConfigContext module
@@ -161,7 +162,7 @@ const mockElectron = {
// Mock appConfig // Mock appConfig
const mockAppConfig = { const mockAppConfig = {
get: vi.fn((key: string) => { get: vi.fn((key: string): string | null => {
if (key === 'GOOSE_WORKING_DIR') return '/test/dir'; if (key === 'GOOSE_WORKING_DIR') return '/test/dir';
return null; return null;
}), }),
@@ -191,6 +192,10 @@ describe('App Component - Brand New State', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockNavigate.mockClear(); mockNavigate.mockClear();
mockSetSearchParams.mockClear(); mockSetSearchParams.mockClear();
mockAppConfig.get.mockImplementation((key: string): string | null => {
if (key === 'GOOSE_WORKING_DIR') return '/test/dir';
return null;
});
// Reset search params // Reset search params
mockSearchParams.forEach((_, key) => { mockSearchParams.forEach((_, key) => {
@@ -290,4 +295,20 @@ describe('App Component - Brand New State', () => {
// App should still initialize without any navigation calls // App should still initialize without any navigation calls
expect(mockNavigate).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled();
}); });
it('should seed recipe sessions with the recipe prompt when no initial message is provided', () => {
expect(
resolveSessionInitialMessage(
{
recipe: {
prompt: 'Write a release note for the latest change',
},
},
undefined
)
).toEqual({
msg: 'Write a release note for the latest change',
images: [],
});
});
}); });
+12 -1
View File
@@ -68,6 +68,16 @@ const HubRouteWrapper = () => {
return <Hub setView={setView} />; return <Hub setView={setView} />;
}; };
export function resolveSessionInitialMessage(
session: { recipe?: { prompt?: string | null } | null },
initialMessage?: UserInput
): UserInput | undefined {
return (
initialMessage ??
(session.recipe?.prompt ? { msg: session.recipe.prompt, images: [] } : undefined)
);
}
const PairRouteWrapper = ({ const PairRouteWrapper = ({
activeSessions, activeSessions,
}: { }: {
@@ -105,12 +115,13 @@ const PairRouteWrapper = ({
recipeId: recipeIdFromConfig, recipeId: recipeIdFromConfig,
allExtensions: extensionsList, allExtensions: extensionsList,
}); });
const sessionInitialMessage = resolveSessionInitialMessage(newSession, initialMessage);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, { new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: { detail: {
sessionId: newSession.id, sessionId: newSession.id,
initialMessage, initialMessage: sessionInitialMessage,
}, },
}) })
); );
+6 -3
View File
@@ -137,12 +137,15 @@ export default function BaseChat({
return initialMessage; return initialMessage;
}, [initialMessage, recipe?.prompt, session?.user_recipe_values]); }, [initialMessage, recipe?.prompt, session?.user_recipe_values]);
const canAutoSubmit = !recipe || hasNotAcceptedRecipe === false;
useAutoSubmit({ useAutoSubmit({
sessionId, sessionId,
session, session,
messages, messages,
chatState, chatState,
initialMessage: resolvedInitialMessage, initialMessage: resolvedInitialMessage,
canAutoSubmit,
handleSubmit, handleSubmit,
}); });
@@ -206,7 +209,7 @@ export default function BaseChat({
const sessionLoaded = session !== undefined; const sessionLoaded = session !== undefined;
useEffect(() => { useEffect(() => {
if (!recipe) return; if (!recipe || !isActiveSession) return;
(async () => { (async () => {
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe); const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
@@ -217,7 +220,7 @@ export default function BaseChat({
setHasRecipeSecurityWarnings(scanResult.has_security_warnings); setHasRecipeSecurityWarnings(scanResult.has_security_warnings);
} }
})(); })();
}, [recipe]); }, [recipe, isActiveSession]);
const handleRecipeAccept = async (accept: boolean) => { const handleRecipeAccept = async (accept: boolean) => {
if (recipe && accept) { if (recipe && accept) {
@@ -525,7 +528,7 @@ export default function BaseChat({
</div> </div>
</MainPanelLayout> </MainPanelLayout>
{recipe && ( {recipe && isActiveSession && (
<RecipeWarningModal <RecipeWarningModal
isOpen={!!hasNotAcceptedRecipe} isOpen={!!hasNotAcceptedRecipe}
onConfirm={() => handleRecipeAccept(true)} onConfirm={() => handleRecipeAccept(true)}
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import type { PropsWithChildren } from 'react';
import { useAutoSubmit } from './useAutoSubmit';
import { ChatState } from '../types/chatState';
import type { Session } from '../api';
import type { UserInput } from '../types/message';
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: 'sess-1',
name: 'untitled',
message_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
working_dir: '/tmp',
extension_data: { active: [], installed: [] },
...overrides,
} as Session;
}
const initialMessage: UserInput = {
msg: 'Run the recipe',
images: [],
};
describe('useAutoSubmit', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not auto-submit while recipe acceptance is unresolved', () => {
const handleSubmit = vi.fn();
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const wrapper = ({ children }: PropsWithChildren) => (
<MemoryRouter initialEntries={['/pair?resumeSessionId=sess-1']}>{children}</MemoryRouter>
);
renderHook(
() =>
useAutoSubmit({
sessionId: 'sess-1',
session: makeSession(),
messages: [],
chatState: ChatState.Idle,
initialMessage,
canAutoSubmit: false,
handleSubmit,
}),
{ wrapper }
);
expect(handleSubmit).not.toHaveBeenCalled();
expect(dispatchEventSpy).not.toHaveBeenCalled();
});
it('auto-submits once recipe acceptance is confirmed', () => {
const handleSubmit = vi.fn();
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const wrapper = ({ children }: PropsWithChildren) => (
<MemoryRouter initialEntries={['/pair?resumeSessionId=sess-1']}>{children}</MemoryRouter>
);
const { rerender } = renderHook(
({ canAutoSubmit }) =>
useAutoSubmit({
sessionId: 'sess-1',
session: makeSession(),
messages: [],
chatState: ChatState.Idle,
initialMessage,
canAutoSubmit,
handleSubmit,
}),
{
initialProps: { canAutoSubmit: false },
wrapper,
}
);
expect(handleSubmit).not.toHaveBeenCalled();
rerender({ canAutoSubmit: true });
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith(initialMessage);
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
});
});
+7
View File
@@ -19,6 +19,7 @@ interface UseAutoSubmitProps {
messages: Message[]; messages: Message[];
chatState: ChatState; chatState: ChatState;
initialMessage: UserInput | undefined; initialMessage: UserInput | undefined;
canAutoSubmit?: boolean;
handleSubmit: (input: UserInput) => void; handleSubmit: (input: UserInput) => void;
} }
@@ -32,6 +33,7 @@ export function useAutoSubmit({
messages, messages,
chatState, chatState,
initialMessage, initialMessage,
canAutoSubmit = true,
handleSubmit, handleSubmit,
}: UseAutoSubmitProps): UseAutoSubmitReturn { }: UseAutoSubmitProps): UseAutoSubmitReturn {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -65,6 +67,10 @@ export function useAutoSubmit({
return; return;
} }
if (!canAutoSubmit) {
return;
}
if (chatState !== ChatState.Idle) { if (chatState !== ChatState.Idle) {
return; return;
} }
@@ -107,6 +113,7 @@ export function useAutoSubmit({
sessionId, sessionId,
messages.length, messages.length,
chatState, chatState,
canAutoSubmit,
clearInitialMessage, clearInitialMessage,
hasUnfilledParameters, hasUnfilledParameters,
]); ]);