feat (ui): change cwd using acp and refactor extension in bottom menubar (#9887)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.sessionWorkingDirUpdate_unstable({ sessionId, workingDir });
|
||||
}
|
||||
|
||||
export async function acpTruncateSessionConversation(
|
||||
sessionId: string,
|
||||
truncateFrom: number
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> | void;
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
||||
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 */}
|
||||
<BottomMenuExtensionSelection sessionId={sessionId} />
|
||||
<BottomMenuExtensionSelection
|
||||
sessionId={sessionId}
|
||||
nextChatExtensionDraft={nextChatExtensionDraft}
|
||||
onNextChatExtensionDraftChange={onNextChatExtensionDraftChange}
|
||||
/>
|
||||
|
||||
{/* Right: diagnostics */}
|
||||
{sessionId && (
|
||||
|
||||
@@ -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<NextChatExtensionDraft | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(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}
|
||||
/>
|
||||
</ChatInputCard>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof getSessionExtensions>[0]['signal'];
|
||||
|
||||
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sessionExtensions, setSessionExtensions] = useState<ExtensionConfig[]>([]);
|
||||
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<string | null>(null);
|
||||
const [isSessionExtensionsLoaded, setIsSessionExtensionsLoaded] = useState(false);
|
||||
const [isSortPending, setIsSortPending] = useState(false);
|
||||
const [togglingExtensionName, setTogglingExtensionName] = useState<string | null>(null);
|
||||
const sortTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<void>,
|
||||
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 (
|
||||
<DraftExtensionsMenu
|
||||
draft={nextChatExtensionDraft}
|
||||
onDraftChange={onNextChatExtensionDraftChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SessionExtensionsMenu sessionId={sessionId} />;
|
||||
};
|
||||
|
||||
function DraftExtensionsMenu({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: NextChatExtensionDraft;
|
||||
onDraftChange: (draft: NextChatExtensionDraft) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const { extensionsList: allExtensions } = useConfig();
|
||||
const [visibleDraft, setVisibleDraft] = useState<NextChatExtensionDraft>(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 (
|
||||
<ExtensionMenu
|
||||
extensions={extensions}
|
||||
title={intl.formatMessage(i18n.manageExtensions)}
|
||||
searchPlaceholder={intl.formatMessage(i18n.searchExtensions)}
|
||||
description={intl.formatMessage(i18n.extensionsForNewChats)}
|
||||
emptyMessage={intl.formatMessage(i18n.noExtensionsAvailable)}
|
||||
noResultsMessage={intl.formatMessage(i18n.noExtensionsFound)}
|
||||
hidden={extensions.length === 0}
|
||||
isTransitioning={isTransitioning}
|
||||
isSortPending={isSortPending}
|
||||
togglingExtensionName={togglingExtensionName}
|
||||
onToggle={handleToggle}
|
||||
onClose={resetTransition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionExtensionsMenu({ sessionId }: { sessionId: string }) {
|
||||
const intl = useIntl();
|
||||
const [sessionExtensions, setSessionExtensions] = useState<ExtensionConfig[]>([]);
|
||||
const [isSessionExtensionsLoaded, setIsSessionExtensionsLoaded] = useState(false);
|
||||
const latestSessionIdRef = useRef(sessionId);
|
||||
const { extensionsList: allExtensions } = useConfig();
|
||||
const isHubView = !sessionId;
|
||||
const {
|
||||
isTransitioning,
|
||||
isSortPending,
|
||||
togglingExtensionName,
|
||||
beginToggle,
|
||||
scheduleSort,
|
||||
resetTransition,
|
||||
} = useExtensionMenuTransition();
|
||||
|
||||
useEffect(() => {
|
||||
latestSessionIdRef.current = sessionId;
|
||||
setIsSessionExtensionsLoaded(false);
|
||||
setSessionExtensions([]);
|
||||
setPendingSort(false);
|
||||
setIsTransitioning(false);
|
||||
setTogglingExtension(null);
|
||||
|
||||
if (sortTimeoutRef.current) {
|
||||
clearTimeout(sortTimeoutRef.current);
|
||||
sortTimeoutRef.current = null;
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sortTimeoutRef.current) {
|
||||
clearTimeout(sortTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
resetTransition();
|
||||
}, [sessionId, resetTransition]);
|
||||
|
||||
const loadSessionExtensions = useCallback(
|
||||
async (targetSessionId: string, signal?: GetSessionExtensionsSignal) => {
|
||||
@@ -126,11 +277,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setIsSessionExtensionsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const loadForSession = (targetSessionId: string) => {
|
||||
@@ -159,8 +305,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
};
|
||||
|
||||
window.addEventListener(AppEvents.SESSION_EXTENSIONS_LOADED, loadExtensionsForCurrentSession);
|
||||
|
||||
// Load immediately in case no SESSION_EXTENSIONS_LOADED event fires for this session.
|
||||
loadForSession(sessionId);
|
||||
|
||||
return () => {
|
||||
@@ -172,59 +316,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
};
|
||||
}, [sessionId, loadSessionExtensions]);
|
||||
|
||||
const finishSessionTransition = useCallback((targetSessionId: string) => {
|
||||
if (latestSessionIdRef.current === targetSessionId) {
|
||||
setPendingSort(false);
|
||||
setIsTransitioning(false);
|
||||
setTogglingExtension(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (extensionConfig: FixedExtensionEntry) => {
|
||||
if (togglingExtension === extensionConfig.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTransitioning(true);
|
||||
setTogglingExtension(extensionConfig.name);
|
||||
|
||||
if (isHubView) {
|
||||
const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
|
||||
setExtensionOverride(extensionConfig.name, !currentState);
|
||||
setPendingSort(true);
|
||||
|
||||
if (sortTimeoutRef.current) {
|
||||
clearTimeout(sortTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Delay the re-sort to allow animation
|
||||
sortTimeoutRef.current = setTimeout(() => {
|
||||
setHubUpdateTrigger((prev) => prev + 1);
|
||||
setPendingSort(false);
|
||||
setIsTransitioning(false);
|
||||
setTogglingExtension(null);
|
||||
sortTimeoutRef.current = null;
|
||||
}, 800);
|
||||
|
||||
toastService.success({
|
||||
title: intl.formatMessage(i18n.extensionUpdated),
|
||||
msg: intl.formatMessage(
|
||||
!currentState ? i18n.extensionWillBeEnabled : i18n.extensionWillBeDisabled,
|
||||
{ name: formatExtensionName(extensionConfig.name) }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
setIsTransitioning(false);
|
||||
setTogglingExtension(null);
|
||||
toastService.error({
|
||||
title: intl.formatMessage(i18n.extensionToggleError),
|
||||
msg: intl.formatMessage(i18n.noActiveSession),
|
||||
traceback: 'No session ID available',
|
||||
});
|
||||
if (!beginToggle(extensionConfig.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,60 +329,42 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
await addToAgent(extensionConfig, sessionId, true);
|
||||
}
|
||||
|
||||
setPendingSort(true);
|
||||
|
||||
if (sortTimeoutRef.current) {
|
||||
clearTimeout(sortTimeoutRef.current);
|
||||
}
|
||||
|
||||
sortTimeoutRef.current = setTimeout(() => {
|
||||
loadSessionExtensions(sessionId)
|
||||
.catch((error) => {
|
||||
scheduleSort(
|
||||
() =>
|
||||
loadSessionExtensions(sessionId).catch((error) => {
|
||||
if (latestSessionIdRef.current === sessionId) {
|
||||
console.error('Failed to fetch session extensions:', error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
finishSessionTransition(sessionId);
|
||||
sortTimeoutRef.current = null;
|
||||
});
|
||||
}, 800);
|
||||
}),
|
||||
{
|
||||
shouldFinish: () => latestSessionIdRef.current === sessionId,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
setIsTransitioning(false);
|
||||
setPendingSort(false);
|
||||
setTogglingExtension(null);
|
||||
resetTransition();
|
||||
}
|
||||
},
|
||||
[sessionId, isHubView, togglingExtension, intl, loadSessionExtensions, finishSessionTransition]
|
||||
[beginToggle, loadSessionExtensions, resetTransition, scheduleSort, sessionId]
|
||||
);
|
||||
|
||||
// Merge all available extensions with session-specific or hub override state
|
||||
const extensionsList = useMemo(() => {
|
||||
const hubOverrides = getExtensionOverrides();
|
||||
|
||||
if (isHubView) {
|
||||
return allExtensions.map(
|
||||
(ext) =>
|
||||
({
|
||||
...ext,
|
||||
enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled,
|
||||
}) as FixedExtensionEntry
|
||||
);
|
||||
}
|
||||
|
||||
const sessionExtensionKeys = new Set(sessionExtensions.map((ext) => nameToKey(ext.name)));
|
||||
const globalExtensionKeys = new Set(allExtensions.map((ext) => nameToKey(ext.name)));
|
||||
const extensions = useMemo(() => {
|
||||
const sessionExtensionKeys = new Set(
|
||||
sessionExtensions.map((extension) => nameToKey(extension.name))
|
||||
);
|
||||
const configuredExtensionKeys = new Set(
|
||||
allExtensions.map((extension) => nameToKey(extension.name))
|
||||
);
|
||||
|
||||
const mergedExtensions = allExtensions.map(
|
||||
(ext) =>
|
||||
(extension) =>
|
||||
({
|
||||
...ext,
|
||||
enabled: sessionExtensionKeys.has(nameToKey(ext.name)),
|
||||
...extension,
|
||||
enabled: sessionExtensionKeys.has(nameToKey(extension.name)),
|
||||
}) as FixedExtensionEntry
|
||||
);
|
||||
|
||||
for (const sessionExtension of sessionExtensions) {
|
||||
if (globalExtensionKeys.has(nameToKey(sessionExtension.name))) {
|
||||
if (configuredExtensionKeys.has(nameToKey(sessionExtension.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -299,124 +375,21 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
}
|
||||
|
||||
return mergedExtensions;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
|
||||
|
||||
const filteredExtensions = useMemo(() => {
|
||||
return extensionsList.filter((ext) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
ext.name.toLowerCase().includes(query) ||
|
||||
(ext.description && ext.description.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
}, [extensionsList, searchQuery]);
|
||||
|
||||
const sortedExtensions = useMemo(() => {
|
||||
return [...filteredExtensions].sort((a, b) => {
|
||||
// Primary sort: enabled first
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
|
||||
|
||||
// Secondary sort: alphabetically by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [filteredExtensions]);
|
||||
|
||||
const activeCount = useMemo(() => {
|
||||
return extensionsList.filter((ext) => ext.enabled).length;
|
||||
}, [extensionsList]);
|
||||
|
||||
const shouldHideTrigger =
|
||||
extensionsList.length === 0 || (!isHubView && !isSessionExtensionsLoaded);
|
||||
}, [allExtensions, sessionExtensions]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
if (sortTimeoutRef.current) {
|
||||
clearTimeout(sortTimeoutRef.current);
|
||||
}
|
||||
setIsTransitioning(false);
|
||||
setPendingSort(false);
|
||||
setTogglingExtension(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${shouldHideTrigger ? 'invisible' : ''}`}
|
||||
title={intl.formatMessage(i18n.manageExtensions)}
|
||||
>
|
||||
<Puzzle className="mr-1 h-4 w-4" />
|
||||
<span>{activeCount}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-64"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(i18n.searchExtensions)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-primary/60 mt-1.5">
|
||||
{intl.formatMessage(
|
||||
isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`max-h-[400px] overflow-y-auto transition-opacity duration-300 ${
|
||||
isTransitioning && pendingSort ? 'opacity-50' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-text-primary/70">
|
||||
{intl.formatMessage(
|
||||
searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
sortedExtensions.map((ext) => {
|
||||
const isToggling = togglingExtension === ext.name;
|
||||
return (
|
||||
<div
|
||||
key={ext.name}
|
||||
className={`flex items-center justify-between px-2 py-2 transition-all duration-300 ${
|
||||
isToggling ? 'cursor-wait opacity-70' : 'cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isToggling && handleToggle(ext)}
|
||||
title={ext.description || ext.name}
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{formatExtensionName(ext.name)}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={ext.enabled}
|
||||
onCheckedChange={() => handleToggle(ext)}
|
||||
variant="mono"
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ExtensionMenu
|
||||
extensions={extensions}
|
||||
title={intl.formatMessage(i18n.manageExtensions)}
|
||||
searchPlaceholder={intl.formatMessage(i18n.searchExtensions)}
|
||||
description={intl.formatMessage(i18n.extensionsForThisSession)}
|
||||
emptyMessage={intl.formatMessage(i18n.noExtensionsAvailable)}
|
||||
noResultsMessage={intl.formatMessage(i18n.noExtensionsFound)}
|
||||
hidden={extensions.length === 0 || !isSessionExtensionsLoaded}
|
||||
isTransitioning={isTransitioning}
|
||||
isSortPending={isSortPending}
|
||||
togglingExtensionName={togglingExtensionName}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { updateWorkingDir } from '../../api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
@@ -48,7 +47,7 @@ interface DirSwitcherProps {
|
||||
className: string;
|
||||
sessionId: string | undefined;
|
||||
workingDir: string;
|
||||
onWorkingDirChange?: (newDir: string) => void;
|
||||
onWorkingDirChange?: (newDir: string) => Promise<void> | void;
|
||||
onRestartStart?: () => void;
|
||||
onRestartEnd?: () => void;
|
||||
}
|
||||
@@ -101,10 +100,7 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
onRestartStart?.();
|
||||
|
||||
try {
|
||||
await updateWorkingDir({
|
||||
body: { session_id: sessionId, working_dir: newDir },
|
||||
});
|
||||
onWorkingDirChange?.(newDir);
|
||||
await onWorkingDirChange?.(newDir);
|
||||
} catch (error) {
|
||||
console.error('[DirSwitcher] Failed to update working directory:', error);
|
||||
toast.error(intl.formatMessage(i18n.failedToUpdateWorkingDir));
|
||||
@@ -112,7 +108,7 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
onRestartEnd?.();
|
||||
}
|
||||
} else {
|
||||
onWorkingDirChange?.(newDir);
|
||||
await onWorkingDirChange?.(newDir);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import type { FixedExtensionEntry } from '../ConfigContext';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
|
||||
import { Input } from '../ui/input';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
|
||||
interface ExtensionMenuProps {
|
||||
extensions: FixedExtensionEntry[];
|
||||
title: string;
|
||||
searchPlaceholder: string;
|
||||
description: string;
|
||||
emptyMessage: string;
|
||||
noResultsMessage: string;
|
||||
hidden: boolean;
|
||||
isTransitioning: boolean;
|
||||
isSortPending: boolean;
|
||||
togglingExtensionName: string | null;
|
||||
onToggle: (extension: FixedExtensionEntry) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ExtensionMenu({
|
||||
extensions,
|
||||
title,
|
||||
searchPlaceholder,
|
||||
description,
|
||||
emptyMessage,
|
||||
noResultsMessage,
|
||||
hidden,
|
||||
isTransitioning,
|
||||
isSortPending,
|
||||
togglingExtensionName,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: ExtensionMenuProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const filteredExtensions = useMemo(() => {
|
||||
return extensions.filter((extension) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
extension.name.toLowerCase().includes(query) ||
|
||||
(extension.description && extension.description.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
}, [extensions, searchQuery]);
|
||||
|
||||
const sortedExtensions = useMemo(() => {
|
||||
return [...filteredExtensions].sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [filteredExtensions]);
|
||||
|
||||
const activeCount = useMemo(() => {
|
||||
return extensions.filter((extension) => extension.enabled).length;
|
||||
}, [extensions]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${hidden ? 'invisible' : ''}`}
|
||||
title={title}
|
||||
>
|
||||
<Puzzle className="mr-1 h-4 w-4" />
|
||||
<span>{activeCount}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-64"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-primary/60 mt-1.5">{description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`max-h-[400px] overflow-y-auto transition-opacity duration-300 ${
|
||||
isTransitioning && isSortPending ? 'opacity-50' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-text-primary/70">
|
||||
{searchQuery ? noResultsMessage : emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
sortedExtensions.map((extension) => {
|
||||
const isToggling = togglingExtensionName === extension.name;
|
||||
return (
|
||||
<div
|
||||
key={extension.name}
|
||||
className={`flex items-center justify-between px-2 py-2 transition-all duration-300 ${
|
||||
isToggling ? 'cursor-wait opacity-70' : 'cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isToggling && onToggle(extension)}
|
||||
title={extension.description || extension.name}
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{formatExtensionName(extension.name)}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={extension.enabled}
|
||||
onCheckedChange={() => onToggle(extension)}
|
||||
variant="mono"
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "Recipe created successfully!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "Extension Toggle Error"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extension Updated"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "manage extensions"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "No active session found. Please start a chat session first."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "no extensions available"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "¡Receta creada con éxito!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "Error al activar/desactivar la extensión"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extensión actualizada"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "administrar extensiones"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "No se encontró ninguna sesión activa. Inicia primero una sesión de chat."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "no hay extensiones disponibles"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "रेसिपी सफलतापूर्वक बनाई गई!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "एक्सटेंशन टॉगल त्रुटि"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "एक्सटेंशन अपडेट किया गया"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "एक्सटेंशन प्रबंधित करें"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "कोई सक्रिय सत्र नहीं मिला. कृपया पहले एक चैट सत्र प्रारंभ करें."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "कोई एक्सटेंशन उपलब्ध नहीं है"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "レシピを作成しました!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "拡張機能の切り替えエラー"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "拡張機能を更新しました"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "拡張機能を管理"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "アクティブなセッションが見つかりません。先にチャットセッションを開始してください。"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "利用可能な拡張機能はありません"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "레시피가 성공적으로 생성되었습니다!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "익스텐션 토글 오류"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "익스텐션이 업데이트되었습니다."
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "익스텐션 관리"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "활성 세션을 찾을 수 없습니다. 먼저 채팅 세션을 시작하세요."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "사용 가능한 익스텐션 없음"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "Рецепт успешно создан!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "Ошибка переключения расширения"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Расширение обновлено"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "управлять расширениями"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "Активный сеанс не найден. Сначала начните чат."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "нет доступных расширений"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "Tarif başarıyla oluşturuldu!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "Uzantı Geçiş Hatası"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Uzantı Güncellendi"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "uzantıları yönet"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "Aktif oturum bulunamadı. Lütfen önce bir sohbet oturumu başlatın."
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "Uzantı yok"
|
||||
},
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
"baseChat.recipeCreatedTitle": {
|
||||
"defaultMessage": "配方创建成功!"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionToggleError": {
|
||||
"defaultMessage": "扩展开关错误"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "扩展已更新"
|
||||
},
|
||||
@@ -146,9 +143,6 @@
|
||||
"bottomMenuExtensionSelection.manageExtensions": {
|
||||
"defaultMessage": "管理扩展"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noActiveSession": {
|
||||
"defaultMessage": "未找到活动会话,请先开始一个聊天会话。"
|
||||
},
|
||||
"bottomMenuExtensionSelection.noExtensionsAvailable": {
|
||||
"defaultMessage": "暂无可用扩展"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Session, startAgent, ExtensionConfig } from './api';
|
||||
import { DEFAULT_CHAT_TITLE } from './contexts/ChatContext';
|
||||
import type { setViewType } from './hooks/useNavigation';
|
||||
import {
|
||||
getExtensionConfigsWithOverrides,
|
||||
clearExtensionOverrides,
|
||||
hasExtensionOverrides,
|
||||
} from './store/extensionOverrides';
|
||||
import type { FixedExtensionEntry } from './components/ConfigContext';
|
||||
import { AppEvents } from './constants/events';
|
||||
import { decodeRecipe, Recipe } from './recipe';
|
||||
@@ -72,13 +67,16 @@ export async function createSession(
|
||||
if (options?.extensionConfigs && options.extensionConfigs.length > 0) {
|
||||
body.extension_overrides = options.extensionConfigs;
|
||||
} else if (options?.allExtensions) {
|
||||
const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions);
|
||||
const extensionConfigs = options.allExtensions
|
||||
.filter((extension) => extension.enabled)
|
||||
.map((extension) => {
|
||||
const { enabled: _enabled, ...config } = extension;
|
||||
return config as ExtensionConfig;
|
||||
});
|
||||
|
||||
if (extensionConfigs.length > 0) {
|
||||
body.extension_overrides = extensionConfigs;
|
||||
}
|
||||
if (hasExtensionOverrides()) {
|
||||
clearExtensionOverrides();
|
||||
}
|
||||
}
|
||||
|
||||
const newAgent = await startAgent({
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Store for extension overrides when starting a new session from the hub
|
||||
// These overrides allow temporarily enabling/disabling extensions before creating a session
|
||||
// Resets after session creation
|
||||
|
||||
import type { ExtensionConfig } from '../api';
|
||||
|
||||
// Map of extension name -> enabled state (overrides from hub view)
|
||||
type ExtensionOverrides = Map<string, boolean>;
|
||||
|
||||
const state: {
|
||||
extensionOverrides: ExtensionOverrides;
|
||||
} = {
|
||||
extensionOverrides: new Map(),
|
||||
};
|
||||
|
||||
export function setExtensionOverride(name: string, enabled: boolean): void {
|
||||
state.extensionOverrides.set(name, enabled);
|
||||
}
|
||||
|
||||
export function getExtensionOverride(name: string): boolean | undefined {
|
||||
return state.extensionOverrides.get(name);
|
||||
}
|
||||
|
||||
export function hasExtensionOverrides(): boolean {
|
||||
return state.extensionOverrides.size > 0;
|
||||
}
|
||||
|
||||
export function getExtensionOverrides(): ExtensionOverrides {
|
||||
return state.extensionOverrides;
|
||||
}
|
||||
|
||||
export function clearExtensionOverrides(): void {
|
||||
state.extensionOverrides.clear();
|
||||
}
|
||||
|
||||
export function getExtensionConfigsWithOverrides(
|
||||
allExtensions: Array<{ name: string; enabled: boolean } & Omit<ExtensionConfig, 'name'>>
|
||||
): ExtensionConfig[] {
|
||||
if (state.extensionOverrides.size === 0) {
|
||||
return allExtensions
|
||||
.filter((ext) => ext.enabled)
|
||||
.map((ext) => {
|
||||
const { enabled: _enabled, ...config } = ext;
|
||||
return config as ExtensionConfig;
|
||||
});
|
||||
}
|
||||
|
||||
return allExtensions
|
||||
.filter((ext) => {
|
||||
if (state.extensionOverrides.has(ext.name)) {
|
||||
return state.extensionOverrides.get(ext.name);
|
||||
}
|
||||
return ext.enabled;
|
||||
})
|
||||
.map((ext) => {
|
||||
const { enabled: _enabled, ...config } = ext;
|
||||
return config as ExtensionConfig;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createNextChatExtensionDraft,
|
||||
selectNextChatExtensions,
|
||||
toggleNextChatExtension,
|
||||
} from './nextChatExtensions';
|
||||
import type { FixedExtensionEntry } from '../components/ConfigContext';
|
||||
|
||||
const extension = (name: string, enabled: boolean): FixedExtensionEntry => ({
|
||||
name,
|
||||
enabled,
|
||||
type: 'builtin',
|
||||
description: `${name} extension`,
|
||||
});
|
||||
|
||||
describe('nextChatExtensions', () => {
|
||||
it('creates a draft from enabled configured extensions', () => {
|
||||
const draft = createNextChatExtensionDraft([
|
||||
extension('developer', true),
|
||||
extension('memory', false),
|
||||
]);
|
||||
|
||||
expect([...draft.selectedNames]).toEqual(['developer']);
|
||||
});
|
||||
|
||||
it('toggles selected extension names', () => {
|
||||
const draft = createNextChatExtensionDraft([extension('developer', true)]);
|
||||
|
||||
const withoutDeveloper = toggleNextChatExtension(draft, extension('developer', true));
|
||||
expect(withoutDeveloper.selectedNames.has('developer')).toBe(false);
|
||||
|
||||
const withMemory = toggleNextChatExtension(withoutDeveloper, extension('memory', false));
|
||||
expect([...withMemory.selectedNames]).toEqual(['memory']);
|
||||
});
|
||||
|
||||
it('selects extension configs without the enabled field', () => {
|
||||
const extensions = [extension('developer', true), extension('memory', false)];
|
||||
const selected = selectNextChatExtensions(extensions, {
|
||||
selectedNames: new Set(['memory']),
|
||||
});
|
||||
|
||||
expect(selected).toEqual([
|
||||
{
|
||||
name: 'memory',
|
||||
type: 'builtin',
|
||||
description: 'memory extension',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ExtensionConfig } from '../api';
|
||||
import type { FixedExtensionEntry } from '../components/ConfigContext';
|
||||
|
||||
export type NextChatExtensionDraft = {
|
||||
selectedNames: Set<string>;
|
||||
};
|
||||
|
||||
export function createNextChatExtensionDraft(
|
||||
allExtensions: FixedExtensionEntry[] = []
|
||||
): NextChatExtensionDraft {
|
||||
return {
|
||||
selectedNames: new Set(
|
||||
allExtensions.filter((extension) => extension.enabled).map((extension) => extension.name)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectNextChatExtensions(
|
||||
allExtensions: FixedExtensionEntry[],
|
||||
draft: NextChatExtensionDraft
|
||||
): ExtensionConfig[] {
|
||||
return allExtensions
|
||||
.filter((extension) => draft.selectedNames.has(extension.name))
|
||||
.map((extension) => {
|
||||
const { enabled: _enabled, ...config } = extension;
|
||||
return config as ExtensionConfig;
|
||||
});
|
||||
}
|
||||
|
||||
export function isNextChatExtensionSelected(
|
||||
extension: FixedExtensionEntry,
|
||||
draft: NextChatExtensionDraft
|
||||
): boolean {
|
||||
return draft.selectedNames.has(extension.name);
|
||||
}
|
||||
|
||||
export function toggleNextChatExtension(
|
||||
draft: NextChatExtensionDraft,
|
||||
extension: FixedExtensionEntry
|
||||
): NextChatExtensionDraft {
|
||||
const selectedNames = new Set(draft.selectedNames);
|
||||
|
||||
if (selectedNames.has(extension.name)) {
|
||||
selectedNames.delete(extension.name);
|
||||
} else {
|
||||
selectedNames.add(extension.name);
|
||||
}
|
||||
|
||||
return { selectedNames };
|
||||
}
|
||||
Reference in New Issue
Block a user