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:
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user