feat (ui): change cwd using acp and refactor extension in bottom menubar (#9887)

This commit is contained in:
Lifei Zhou
2026-06-20 02:25:54 +10:00
committed by GitHub
parent 093d134b0a
commit 6c2ec554de
20 changed files with 641 additions and 406 deletions
+18 -3
View File
@@ -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;
+18 -8
View File
@@ -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 && (
+29 -11
View File
@@ -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>
);
}