diff --git a/ui/desktop/src/__tests__/createSession.test.ts b/ui/desktop/src/__tests__/createSession.test.ts new file mode 100644 index 000000000..17cede662 --- /dev/null +++ b/ui/desktop/src/__tests__/createSession.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { startAgent } from '../api'; +import { createSession } from '../sessions'; +import type { ExtensionConfig, Session } from '../api'; +import type { FixedExtensionEntry } from '../components/ConfigContext'; + +vi.mock('../api', () => ({ + startAgent: vi.fn(), +})); + +const testSession: Session = { + id: 'session-1', + name: 'untitled', + message_count: 0, + created_at: '2026-06-19T00:00:00.000Z', + updated_at: '2026-06-19T00:00:00.000Z', + working_dir: '/tmp', + extension_data: { active: [], installed: [] }, +}; + +const extensionConfig = (name: string): ExtensionConfig => ({ + name, + type: 'builtin', + description: `${name} extension`, +}); + +const configuredExtension = (name: string, enabled: boolean): FixedExtensionEntry => ({ + ...extensionConfig(name), + enabled, +}); + +const mockedStartAgent = vi.mocked(startAgent); + +describe('createSession extension overrides', () => { + beforeEach(() => { + mockedStartAgent.mockReset(); + mockedStartAgent.mockResolvedValue({ + data: testSession, + error: undefined, + request: new globalThis.Request('http://localhost/sessions'), + response: new globalThis.Response(), + }); + }); + + it('sends non-empty extension configs as overrides', async () => { + await createSession('/tmp', { + extensionConfigs: [extensionConfig('developer')], + }); + + expect(mockedStartAgent).toHaveBeenCalledWith({ + body: { + working_dir: '/tmp', + extension_overrides: [extensionConfig('developer')], + }, + throwOnError: true, + }); + }); + + it('falls back to enabled configured extensions when extension configs are empty', async () => { + await createSession('/tmp', { + extensionConfigs: [], + allExtensions: [configuredExtension('developer', true), configuredExtension('memory', false)], + }); + + expect(mockedStartAgent).toHaveBeenCalledWith({ + body: { + working_dir: '/tmp', + extension_overrides: [extensionConfig('developer')], + }, + throwOnError: true, + }); + }); + + it('omits extension overrides when no configured extensions are enabled', async () => { + await createSession('/tmp', { + allExtensions: [configuredExtension('developer', false)], + }); + + expect(mockedStartAgent).toHaveBeenCalledWith({ + body: { + working_dir: '/tmp', + }, + throwOnError: true, + }); + }); +}); diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 3e748db1c..d6db5a370 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -207,6 +207,11 @@ export async function acpRenameSession(sessionId: string, title: string): Promis await client.goose.sessionRename_unstable({ sessionId, title }); } +export async function acpUpdateWorkingDir(sessionId: string, workingDir: string): Promise { + const client = await getAcpClient(); + await client.goose.sessionWorkingDirUpdate_unstable({ sessionId, workingDir }); +} + export async function acpTruncateSessionConversation( sessionId: string, truncateFrom: number diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 97a6f0ebe..63e21577c 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -10,13 +10,15 @@ import ChatInput from './ChatInput'; import { ChatInputCard } from './ChatInputCard'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; -import { Message } from '../api'; +import { Message, updateWorkingDir } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; import { useChatSession } from '../hooks/useChatSession'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpUpdateWorkingDir } from '../acp/sessions'; import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; @@ -122,10 +124,23 @@ export default function BaseChat({ }); const handleWorkingDirChange = useCallback( - (newDir: string) => { + async (newDir: string) => { + if (USE_ACP_CHAT) { + if (!session) { + throw new Error('Cannot update working directory before ACP session is loaded'); + } + + await acpUpdateWorkingDir(session.id, newDir); + } else { + await updateWorkingDir({ + body: { session_id: sessionId, working_dir: newDir }, + throwOnError: true, + }); + } + updateSession((currentSession) => ({ ...currentSession, working_dir: newDir })); }, - [updateSession] + [session, sessionId, updateSession] ); const recipe = session?.recipe; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index f1ca7d292..70be726f4 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -16,6 +16,7 @@ import { cn } from '../utils'; import { AlertType, useAlerts } from './alerts'; import { useConfig } from './ConfigContext'; import { useModelAndProvider } from './ModelAndProviderContext'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { useAudioRecorder } from '../hooks/useAudioRecorder'; import { toastError } from '../toasts'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; @@ -37,6 +38,7 @@ import { compressImageDataUrl } from '../utils/conversionUtils'; import { fetchCanonicalModelInfo } from '../utils/canonical'; import { defineMessages, useIntl } from '../i18n'; import TurndownService from 'turndown'; +import type { NextChatExtensionDraft } from '../utils/nextChatExtensions'; const turndown = new TurndownService({ headingStyle: 'atx', @@ -190,13 +192,15 @@ interface ChatInputProps { initialPrompt?: string; toolCount: number; append?: (message: Message) => void; - onWorkingDirChange?: (newDir: string) => void; + onWorkingDirChange?: (newDir: string) => Promise | void; inputRef?: React.RefObject; sessionModel?: string | null; sessionProvider?: string | null; sessionLoaded?: boolean; workingDir?: string | null; latestInference?: Message['metadata']['inference'] | null; + nextChatExtensionDraft?: NextChatExtensionDraft; + onNextChatExtensionDraftChange?: (draft: NextChatExtensionDraft) => void; } export default function ChatInput({ @@ -230,6 +234,8 @@ export default function ChatInput({ sessionLoaded, workingDir, latestInference, + nextChatExtensionDraft, + onNextChatExtensionDraftChange, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -1602,14 +1608,14 @@ export default function ChatInput({ className="" sessionId={sessionId ?? undefined} workingDir={currentWorkingDir} - onWorkingDirChange={(newDir) => { + onWorkingDirChange={async (newDir) => { + await onWorkingDirChange?.(newDir); setWorkingDirOverride(newDir); - if (onWorkingDirChange) { - onWorkingDirChange(newDir); - } }} - onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} - onRestartEnd={() => setChatState?.(ChatState.Idle)} + onRestartStart={ + USE_ACP_CHAT ? undefined : () => setChatState?.(ChatState.RestartingAgent) + } + onRestartEnd={USE_ACP_CHAT ? undefined : () => setChatState?.(ChatState.Idle)} /> )} @@ -1637,7 +1643,11 @@ export default function ChatInput({ /> {/* Right: extension selector */} - + {/* Right: diagnostics */} {sessionId && ( diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 0fd38f32d..d00bfabf9 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,7 +7,7 @@ * lives there. */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { defineMessages, useIntl } from '../i18n'; import { AppEvents } from '../constants/events'; import ChatInput from './ChatInput'; @@ -16,14 +16,15 @@ import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; import { useConfig } from './ConfigContext'; -import { - clearExtensionOverrides, - getExtensionConfigsWithOverrides, -} from '../store/extensionOverrides'; import { getInitialWorkingDir } from '../utils/workingDir'; import { createSession } from '../sessions'; import LoadingGoose from './LoadingGoose'; import { UserInput } from '../types/message'; +import { + createNextChatExtensionDraft, + selectNextChatExtensions, + type NextChatExtensionDraft, +} from '../utils/nextChatExtensions'; const i18n = defineMessages({ goodMorning: { id: 'hub.goodMorning', defaultMessage: 'Good morning' }, @@ -55,6 +56,8 @@ export default function Hub({ const { extensionsList } = useConfig(); const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); const [isCreatingSession, setIsCreatingSession] = useState(false); + const [nextChatExtensionDraft, setNextChatExtensionDraft] = + useState(null); const inputRef = useRef(null); const { time, meridiem, hour } = useClock(); @@ -64,6 +67,11 @@ export default function Hub({ return intl.formatMessage(i18n.goodEvening); }, [intl, hour]); + const draftForMenu = useMemo( + () => nextChatExtensionDraft ?? createNextChatExtensionDraft(extensionsList), + [extensionsList, nextChatExtensionDraft] + ); + // rAF is more reliable than autoFocus across async render boundaries. useEffect(() => { const frameId = requestAnimationFrame(() => { @@ -72,19 +80,27 @@ export default function Hub({ return () => cancelAnimationFrame(frameId); }, []); + const handleNextChatExtensionDraftChange = useCallback((draft: NextChatExtensionDraft) => { + setNextChatExtensionDraft(draft); + }, []); + const handleSubmit = async (input: UserInput) => { const { msg: userMessage, images } = input; if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return; - const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); - clearExtensionOverrides(); setIsCreatingSession(true); try { - const session = await createSession(workingDir, { - extensionConfigs, - allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList, - }); + const selectedExtensions = nextChatExtensionDraft + ? selectNextChatExtensions(extensionsList, nextChatExtensionDraft) + : []; + const sessionOptions = + selectedExtensions.length > 0 + ? { extensionConfigs: selectedExtensions } + : { allExtensions: extensionsList }; + + const session = await createSession(workingDir, sessionOptions); + setNextChatExtensionDraft(null); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); window.dispatchEvent( @@ -133,6 +149,8 @@ export default function Hub({ toolCount={0} onWorkingDirChange={setWorkingDir} inputRef={inputRef} + nextChatExtensionDraft={draftForMenu} + onNextChatExtensionDraftChange={handleNextChatExtensionDraftChange} /> diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 1c7ca464a..bd4127033 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -1,21 +1,18 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { Puzzle } from 'lucide-react'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'; -import { Input } from '../ui/input'; -import { Switch } from '../ui/switch'; -import { FixedExtensionEntry, useConfig } from '../ConfigContext'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useConfig, type FixedExtensionEntry } from '../ConfigContext'; import { toastService } from '../../toasts'; import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { nameToKey } from '../settings/extensions/utils'; import { ExtensionConfig, getSessionExtensions } from '../../api'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; -import { - setExtensionOverride, - getExtensionOverride, - getExtensionOverrides, -} from '../../store/extensionOverrides'; import { defineMessages, useIntl } from '../../i18n'; import { AppEvents } from '../../constants/events'; +import { ExtensionMenu } from './ExtensionMenu'; +import { + isNextChatExtensionSelected, + toggleNextChatExtension, + type NextChatExtensionDraft, +} from '../../utils/nextChatExtensions'; const i18n = defineMessages({ manageExtensions: { @@ -54,58 +51,212 @@ const i18n = defineMessages({ id: 'bottomMenuExtensionSelection.extensionWillBeDisabled', defaultMessage: '{name} will be disabled in new chats', }, - extensionToggleError: { - id: 'bottomMenuExtensionSelection.extensionToggleError', - defaultMessage: 'Extension Toggle Error', - }, - noActiveSession: { - id: 'bottomMenuExtensionSelection.noActiveSession', - defaultMessage: 'No active session found. Please start a chat session first.', - }, }); interface BottomMenuExtensionSelectionProps { sessionId: string | null; + nextChatExtensionDraft?: NextChatExtensionDraft; + onNextChatExtensionDraftChange?: (draft: NextChatExtensionDraft) => void; } type GetSessionExtensionsSignal = Parameters[0]['signal']; -export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { - const intl = useIntl(); - const [searchQuery, setSearchQuery] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const [sessionExtensions, setSessionExtensions] = useState([]); - const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); +const EXTENSION_SORT_DELAY_MS = 800; + +function useExtensionMenuTransition() { const [isTransitioning, setIsTransitioning] = useState(false); - const [pendingSort, setPendingSort] = useState(false); - const [togglingExtension, setTogglingExtension] = useState(null); - const [isSessionExtensionsLoaded, setIsSessionExtensionsLoaded] = useState(false); + const [isSortPending, setIsSortPending] = useState(false); + const [togglingExtensionName, setTogglingExtensionName] = useState(null); const sortTimeoutRef = useRef | null>(null); + + const clearSortTimeout = useCallback(() => { + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + sortTimeoutRef.current = null; + } + }, []); + + const resetTransition = useCallback(() => { + clearSortTimeout(); + setIsTransitioning(false); + setIsSortPending(false); + setTogglingExtensionName(null); + }, [clearSortTimeout]); + + const beginToggle = useCallback( + (extensionName: string) => { + if (togglingExtensionName === extensionName) { + return false; + } + + setIsTransitioning(true); + setTogglingExtensionName(extensionName); + return true; + }, + [togglingExtensionName] + ); + + const finishTransition = useCallback(() => { + setIsSortPending(false); + setIsTransitioning(false); + setTogglingExtensionName(null); + }, []); + + const scheduleSort = useCallback( + ( + callback: () => void | Promise, + options?: { + shouldFinish?: () => boolean; + } + ) => { + setIsSortPending(true); + clearSortTimeout(); + + sortTimeoutRef.current = setTimeout(() => { + Promise.resolve() + .then(callback) + .finally(() => { + sortTimeoutRef.current = null; + if (options?.shouldFinish?.() ?? true) { + finishTransition(); + } + }); + }, EXTENSION_SORT_DELAY_MS); + }, + [clearSortTimeout, finishTransition] + ); + + useEffect(() => clearSortTimeout, [clearSortTimeout]); + + return { + isTransitioning, + isSortPending, + togglingExtensionName, + beginToggle, + scheduleSort, + resetTransition, + }; +} + +export const BottomMenuExtensionSelection = ({ + sessionId, + nextChatExtensionDraft, + onNextChatExtensionDraftChange, +}: BottomMenuExtensionSelectionProps) => { + if (!sessionId) { + if (!nextChatExtensionDraft || !onNextChatExtensionDraftChange) { + return null; + } + + return ( + + ); + } + + return ; +}; + +function DraftExtensionsMenu({ + draft, + onDraftChange, +}: { + draft: NextChatExtensionDraft; + onDraftChange: (draft: NextChatExtensionDraft) => void; +}) { + const intl = useIntl(); + const { extensionsList: allExtensions } = useConfig(); + const [visibleDraft, setVisibleDraft] = useState(draft); + const { + isTransitioning, + isSortPending, + togglingExtensionName, + beginToggle, + scheduleSort, + resetTransition, + } = useExtensionMenuTransition(); + + useEffect(() => { + if (!isTransitioning) { + setVisibleDraft(draft); + } + }, [draft, isTransitioning]); + + const handleToggle = useCallback( + (extensionConfig: FixedExtensionEntry) => { + if (!beginToggle(extensionConfig.name)) { + return; + } + + const currentState = isNextChatExtensionSelected(extensionConfig, draft); + const nextDraft = toggleNextChatExtension(draft, extensionConfig); + onDraftChange(nextDraft); + scheduleSort(() => { + setVisibleDraft(nextDraft); + }); + + toastService.success({ + title: intl.formatMessage(i18n.extensionUpdated), + msg: intl.formatMessage( + !currentState ? i18n.extensionWillBeEnabled : i18n.extensionWillBeDisabled, + { name: formatExtensionName(extensionConfig.name) } + ), + }); + }, + [beginToggle, draft, intl, onDraftChange, scheduleSort] + ); + + const extensions = useMemo(() => { + return allExtensions.map( + (extension) => + ({ + ...extension, + enabled: isNextChatExtensionSelected(extension, visibleDraft), + }) as FixedExtensionEntry + ); + }, [allExtensions, visibleDraft]); + + return ( +