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,
]);