diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index fcbb69c6..d594441e 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { screen, render, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { AppInner } from './App'; +import { AppInner, resolveSessionInitialMessage } from './App'; import { IntlTestWrapper } from './i18n/test-utils'; // Set up globals for jsdom @@ -60,6 +60,7 @@ vi.mock('./sessions', () => ({ .fn() .mockResolvedValue({ sessionId: 'test', messages: [], metadata: { description: '' } }), generateSessionId: vi.fn(), + createSession: vi.fn(), })); // Mock the ConfigContext module @@ -161,7 +162,7 @@ const mockElectron = { // Mock appConfig const mockAppConfig = { - get: vi.fn((key: string) => { + get: vi.fn((key: string): string | null => { if (key === 'GOOSE_WORKING_DIR') return '/test/dir'; return null; }), @@ -191,6 +192,10 @@ describe('App Component - Brand New State', () => { vi.clearAllMocks(); mockNavigate.mockClear(); mockSetSearchParams.mockClear(); + mockAppConfig.get.mockImplementation((key: string): string | null => { + if (key === 'GOOSE_WORKING_DIR') return '/test/dir'; + return null; + }); // Reset search params mockSearchParams.forEach((_, key) => { @@ -290,4 +295,20 @@ describe('App Component - Brand New State', () => { // App should still initialize without any navigation calls 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: [], + }); + }); }); diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 445997e6..ca32d04d 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -68,6 +68,16 @@ const HubRouteWrapper = () => { return ; }; +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 = ({ activeSessions, }: { @@ -105,12 +115,13 @@ const PairRouteWrapper = ({ recipeId: recipeIdFromConfig, allExtensions: extensionsList, }); + const sessionInitialMessage = resolveSessionInitialMessage(newSession, initialMessage); window.dispatchEvent( new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, { detail: { sessionId: newSession.id, - initialMessage, + initialMessage: sessionInitialMessage, }, }) ); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 88891bc0..23e80ba8 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -137,12 +137,15 @@ export default function BaseChat({ return initialMessage; }, [initialMessage, recipe?.prompt, session?.user_recipe_values]); + const canAutoSubmit = !recipe || hasNotAcceptedRecipe === false; + useAutoSubmit({ sessionId, session, messages, chatState, initialMessage: resolvedInitialMessage, + canAutoSubmit, handleSubmit, }); @@ -206,7 +209,7 @@ export default function BaseChat({ const sessionLoaded = session !== undefined; useEffect(() => { - if (!recipe) return; + if (!recipe || !isActiveSession) return; (async () => { const accepted = await window.electron.hasAcceptedRecipeBefore(recipe); @@ -217,7 +220,7 @@ export default function BaseChat({ setHasRecipeSecurityWarnings(scanResult.has_security_warnings); } })(); - }, [recipe]); + }, [recipe, isActiveSession]); const handleRecipeAccept = async (accept: boolean) => { if (recipe && accept) { @@ -525,7 +528,7 @@ export default function BaseChat({ - {recipe && ( + {recipe && isActiveSession && ( handleRecipeAccept(true)} diff --git a/ui/desktop/src/hooks/useAutoSubmit.test.tsx b/ui/desktop/src/hooks/useAutoSubmit.test.tsx new file mode 100644 index 00000000..fce4395b --- /dev/null +++ b/ui/desktop/src/hooks/useAutoSubmit.test.tsx @@ -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 { + 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) => ( + {children} + ); + + 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) => ( + {children} + ); + + 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); + }); +}); diff --git a/ui/desktop/src/hooks/useAutoSubmit.ts b/ui/desktop/src/hooks/useAutoSubmit.ts index 4d4eb525..a64d653c 100644 --- a/ui/desktop/src/hooks/useAutoSubmit.ts +++ b/ui/desktop/src/hooks/useAutoSubmit.ts @@ -19,6 +19,7 @@ interface UseAutoSubmitProps { messages: Message[]; chatState: ChatState; initialMessage: UserInput | undefined; + canAutoSubmit?: boolean; handleSubmit: (input: UserInput) => void; } @@ -32,6 +33,7 @@ export function useAutoSubmit({ messages, chatState, initialMessage, + canAutoSubmit = true, handleSubmit, }: UseAutoSubmitProps): UseAutoSubmitReturn { const [searchParams] = useSearchParams(); @@ -65,6 +67,10 @@ export function useAutoSubmit({ return; } + if (!canAutoSubmit) { + return; + } + if (chatState !== ChatState.Idle) { return; } @@ -107,6 +113,7 @@ export function useAutoSubmit({ sessionId, messages.length, chatState, + canAutoSubmit, clearInitialMessage, hasUnfilledParameters, ]);