Redesign Extensions page. Remove enable toggle in UI. Treat Extension Manager as core MCP enabler per session. (#8940)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-01 19:16:55 -07:00
committed by GitHub
parent c365e7b950
commit 45d8bf81d0
67 changed files with 2415 additions and 980 deletions
-3
View File
@@ -5211,9 +5211,6 @@
},
{
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
+1 -1
View File
@@ -465,7 +465,7 @@ export type ExtensionData = {
};
export type ExtensionEntry = ExtensionConfig & {
enabled: boolean;
enabled?: boolean;
};
export type ExtensionLoadResult = {
+12 -4
View File
@@ -18,6 +18,7 @@ import type {
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
ExtensionEntry,
} from '../api';
export type { ExtensionConfig } from '../api/types.gen';
@@ -27,6 +28,12 @@ export type FixedExtensionEntry = ExtensionConfig & {
enabled: boolean;
};
const normalizeExtensions = (extensions: ExtensionEntry[]): FixedExtensionEntry[] =>
extensions.map((extension) => ({
...extension,
enabled: extension.enabled ?? true,
}));
interface ConfigContextType {
config: ConfigResponse['config'];
providersList: ProviderDetails[];
@@ -126,9 +133,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
}
const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
const extensions = normalizeExtensions(extensionResponse.extensions);
setExtensionsList(extensions);
setExtensionWarnings(extensionResponse.warnings || []);
return extensionResponse.extensions;
return extensions;
}, [extensionsList]);
const addExtension = useCallback(
@@ -213,7 +221,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load extensions
try {
const extensionsResponse = await apiGetExtensions();
let extensions = extensionsResponse.data?.extensions || [];
let extensions = normalizeExtensions(extensionsResponse.data?.extensions || []);
// Always sync bundled extensions from bundled-extensions.json
// This ensures:
@@ -236,7 +244,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync
const refreshedResponse = await apiGetExtensions();
extensions = refreshedResponse.data?.extensions || [];
extensions = normalizeExtensions(refreshedResponse.data?.extensions || []);
setExtensionsList(extensions);
setExtensionWarnings(extensionsResponse.data?.warnings || []);
@@ -27,12 +27,12 @@ const i18n = defineMessages({
description: {
id: 'extensionsView.description',
defaultMessage:
'These extensions use the Model Context Protocol (MCP). They can expand Goose\'s capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search.',
"These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search.",
},
defaultNote: {
id: 'extensionsView.defaultNote',
defaultMessage:
'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.',
'Extensions stay available here, and Goose can load them on demand during a chat.',
},
addCustomExtension: {
id: 'extensionsView.addCustomExtension',
@@ -155,9 +155,7 @@ export default function ExtensionsView({
<Button
className="flex items-center gap-2 justify-center"
variant="secondary"
onClick={() =>
window.open('https://goose-docs.ai/v1/extensions/', '_blank')
}
onClick={() => window.open('https://goose-docs.ai/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
{intl.formatMessage(i18n.browseExtensions)}
@@ -167,7 +165,10 @@ export default function ExtensionsView({
</div>
<div className="px-8 pb-16">
<SearchView onSearch={(term) => setSearchTerm(term)} placeholder={intl.formatMessage(i18n.searchPlaceholder)}>
<SearchView
onSearch={(term) => setSearchTerm(term)}
placeholder={intl.formatMessage(i18n.searchPlaceholder)}
>
<ExtensionsSection
key={refreshKey}
deepLinkConfig={viewOptions.deepLinkConfig}
@@ -13,7 +13,7 @@ import {
getDefaultFormData,
} from './utils';
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
import { activateExtensionDefault, deleteExtension } from './index';
import { ExtensionConfig } from '../../../api/types.gen';
const i18n = defineMessages({
@@ -44,8 +44,6 @@ interface ExtensionSectionProps {
showEnvVars?: boolean;
hideButtons?: boolean;
disableConfiguration?: boolean;
customToggle?: (extension: FixedExtensionEntry) => Promise<boolean | void>;
selectedExtensions?: string[]; // Add controlled state
onModalClose?: (extensionName: string) => void;
searchTerm?: string;
}
@@ -55,8 +53,6 @@ export default function ExtensionsSection({
showEnvVars,
hideButtons,
disableConfiguration,
customToggle,
selectedExtensions = [],
onModalClose,
searchTerm = '',
}: ExtensionSectionProps) {
@@ -80,50 +76,26 @@ export default function ExtensionsSection({
const extensions = useMemo(() => {
if (extensionsList.length === 0) return [];
return [...extensionsList]
.sort((a, b) => {
// First sort by builtin
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
return [...extensionsList].sort((a, b) => {
// First sort by builtin
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
// Then sort by bundled (handle null/undefined cases)
const aBundled = 'bundled' in a && a.bundled === true;
const bBundled = 'bundled' in b && b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
// Then sort by bundled (handle null/undefined cases)
const aBundled = 'bundled' in a && a.bundled === true;
const bBundled = 'bundled' in b && b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
// Finally sort alphabetically within each group
return a.name.localeCompare(b.name);
})
.map((ext) => ({
...ext,
// Use selectedExtensions to determine enabled state in recipe editor
enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled,
}));
}, [extensionsList, disableConfiguration, selectedExtensions]);
// Finally sort alphabetically within each group
return a.name.localeCompare(b.name);
});
}, [extensionsList]);
const fetchExtensions = useCallback(async () => {
await getExtensions(true); // Force refresh - this will update the context
}, [getExtensions]);
const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => {
if (customToggle) {
await customToggle(extensionConfig);
return true;
}
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
await toggleExtensionDefault({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
});
await fetchExtensions();
return true;
};
const handleConfigureClick = (extension: FixedExtensionEntry) => {
setSelectedExtension(extension);
setIsModalOpen(true);
@@ -209,7 +181,6 @@ export default function ExtensionsSection({
<div className="">
<ExtensionList
extensions={extensions}
onToggle={handleExtensionToggle}
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
searchTerm={searchTerm}
@@ -228,9 +199,7 @@ export default function ExtensionsSection({
<Button
className="flex items-center gap-2 justify-center"
variant="secondary"
onClick={() =>
window.open('https://goose-docs.ai/v1/extensions/', '_blank')
}
onClick={() => window.open('https://goose-docs.ai/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
{intl.formatMessage(i18n.browseExtensions)}
@@ -269,7 +238,7 @@ export default function ExtensionsSection({
title={intl.formatMessage(i18n.addCustomExtension)}
initialData={extensionToFormData({
...deepLinkConfig,
enabled: true,
enabled: false,
} as FixedExtensionEntry)}
onClose={handleModalClose}
onSubmit={handleAddExtension}
@@ -9,7 +9,7 @@ vi.mock('./bundled-extensions.json', () => ({
name: 'developer',
display_name: 'Developer',
description: 'General development tools.',
enabled: true,
enabled: false,
type: 'builtin',
timeout: 300,
},
@@ -18,7 +18,7 @@ vi.mock('./bundled-extensions.json', () => ({
name: 'googledrive',
display_name: 'Google Drive',
description: 'Google Drive integration.',
enabled: true,
enabled: false,
type: 'stdio',
cmd: 'googledrive-mcp',
args: [],
@@ -130,7 +130,7 @@ describe('pruneDeprecatedBundledExtensions', () => {
name: 'googledrive',
bundled: true,
}),
true
false
);
});
});
@@ -178,15 +178,14 @@ export async function addExtensionFromDeepLink(
return;
}
// Note: deeplink activation doesn't have access to sessionId
// The extension will be added to config but not activated in the current session
// It will be activated when the next session starts
await addExtensionFn(config.name, config, true);
// Note: deeplink activation doesn't have access to sessionId, so the extension
// is saved for on-demand use instead of being globally enabled.
await addExtensionFn(config.name, config, false);
// Show success toast and navigate to extensions page
toastService.success({
title: 'Extension Installed',
msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`,
msg: `${config.name} extension has been installed successfully and is available on demand.`,
});
// Navigate to extensions page to show the newly installed extension
@@ -1,12 +1,6 @@
import type { ExtensionConfig } from '../../../api/types.gen';
import { toastService } from '../../../toasts';
import {
trackExtensionAdded,
trackExtensionEnabled,
trackExtensionDisabled,
trackExtensionDeleted,
getErrorType,
} from '../../../utils/analytics';
import { trackExtensionAdded, trackExtensionDeleted, getErrorType } from '../../../utils/analytics';
function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
@@ -38,46 +32,6 @@ export async function deleteExtension({
}
}
interface ToggleExtensionDefaultProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
}
export async function toggleExtensionDefault({
toggle,
extensionConfig,
addToConfig,
}: ToggleExtensionDefaultProps) {
const isBuiltin = isBuiltinExtension(extensionConfig);
const enabled = toggle === 'toggleOn';
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
if (enabled) {
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
} else {
trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
}
toastService.success({
title: extensionConfig.name,
msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults',
});
} catch (error) {
console.error('Failed to update extension default in config:', error);
if (enabled) {
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
} else {
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
}
toastService.error({
title: extensionConfig.name,
msg: 'Failed to update extension default',
});
throw error;
}
}
interface ActivateExtensionDefaultProps {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
extensionConfig: ExtensionConfig;
@@ -90,11 +44,11 @@ export async function activateExtensionDefault({
const isBuiltin = isBuiltinExtension(extensionConfig);
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
await addToConfig(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
toastService.success({
title: extensionConfig.name,
msg: 'Extension added as default',
msg: 'Extension added',
});
} catch (error) {
console.error('Failed to add extension to config:', error);
@@ -1,10 +1,6 @@
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
export {
activateExtensionDefault,
toggleExtensionDefault,
deleteExtension,
} from './extension-manager';
export { activateExtensionDefault, deleteExtension } from './extension-manager';
export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
@@ -1,6 +1,4 @@
import { useState, useEffect } from 'react';
import kebabCase from 'lodash/kebabCase';
import { Switch } from '../../../ui/switch';
import { Gear } from '../../../icons';
import { FixedExtensionEntry } from '../../../ConfigContext';
import { getSubtitle, getFriendlyTitle } from './ExtensionList';
@@ -12,59 +10,16 @@ const i18n = defineMessages({
id: 'extensionItem.configureExtension',
defaultMessage: 'Configure {name} Extension',
},
toggleExtension: {
id: 'extensionItem.toggleExtension',
defaultMessage: 'Toggle {name} extension On or Off',
},
});
interface ExtensionItemProps {
extension: FixedExtensionEntry;
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean; // to not allow users to edit configuration
}
export default function ExtensionItem({
extension,
onToggle,
onConfigure,
isStatic,
}: ExtensionItemProps) {
export default function ExtensionItem({ extension, onConfigure, isStatic }: ExtensionItemProps) {
const intl = useIntl();
// Add local state to track the visual toggle state
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
// Track if we're in the process of toggling
const [isToggling, setIsToggling] = useState(false);
const handleToggle = async (ext: FixedExtensionEntry) => {
// Prevent multiple toggles while one is in progress
if (isToggling) return;
setIsToggling(true);
// Immediately update visual state
const newState = !ext.enabled;
setVisuallyEnabled(newState);
try {
// Call the actual toggle function that performs the async operation
await onToggle(ext);
// Success case is handled by the useEffect below when extension.enabled changes
} catch {
// If there was an error, revert the visual state
setVisuallyEnabled(!newState);
} finally {
setIsToggling(false);
}
};
// Update visual state when the actual extension state changes
useEffect(() => {
if (!isToggling) {
setVisuallyEnabled(extension.enabled);
}
}, [extension.enabled, isToggling]);
const renderSubtitle = () => {
const { description, command } = getSubtitle(extension);
@@ -96,20 +51,16 @@ export default function ExtensionItem({
<div className="flex items-center justify-end gap-2">
{editable && (
<button
type="button"
className="text-text-secondary hover:text-text-primary"
aria-label={intl.formatMessage(i18n.configureExtension, { name: getFriendlyTitle(extension) })}
aria-label={intl.formatMessage(i18n.configureExtension, {
name: getFriendlyTitle(extension),
})}
onClick={() => onConfigure?.(extension)}
>
<Gear className="w-4 h-4" />
</button>
)}
<Switch
checked={(isToggling && visuallyEnabled) || extension.enabled}
onCheckedChange={() => handleToggle(extension)}
disabled={isToggling}
variant="mono"
aria-label={intl.formatMessage(i18n.toggleExtension, { name: getFriendlyTitle(extension) })}
/>
</div>
</CardAction>
</CardHeader>
@@ -8,11 +8,11 @@ import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
defaultExtensions: {
id: 'extensionList.defaultExtensions',
defaultMessage: 'Default Extensions ({count})',
defaultMessage: 'Active by Default ({count})',
},
availableExtensions: {
id: 'extensionList.availableExtensions',
defaultMessage: 'Available Extensions ({count})',
defaultMessage: 'Available On Demand ({count})',
},
noExtensions: {
id: 'extensionList.noExtensions',
@@ -26,7 +26,6 @@ const i18n = defineMessages({
interface ExtensionListProps {
extensions: FixedExtensionEntry[];
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean;
disableConfiguration?: boolean;
@@ -35,10 +34,9 @@ interface ExtensionListProps {
export default function ExtensionList({
extensions,
onToggle,
onConfigure,
isStatic,
disableConfiguration: _disableConfiguration,
disableConfiguration,
searchTerm = '',
}: ExtensionListProps) {
const matchesSearch = (extension: FixedExtensionEntry): boolean => {
@@ -68,6 +66,9 @@ export default function ExtensionList({
const sortedDisabledExtensions = [...disabledExtensions].sort((a, b) =>
getFriendlyTitle(a).localeCompare(getFriendlyTitle(b))
);
const configureHandler = disableConfiguration ? undefined : onConfigure;
const hasVisibleExtensions =
sortedEnabledExtensions.length > 0 || sortedDisabledExtensions.length > 0;
return (
<div className="space-y-8">
@@ -82,8 +83,7 @@ export default function ExtensionList({
<ExtensionItem
key={extension.name}
extension={extension}
onToggle={onToggle}
onConfigure={onConfigure}
onConfigure={configureHandler}
isStatic={isStatic}
/>
))}
@@ -95,15 +95,16 @@ export default function ExtensionList({
<div>
<h2 className="text-lg font-medium text-text-secondary mb-4 flex items-center gap-2">
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
{intl.formatMessage(i18n.availableExtensions, { count: sortedDisabledExtensions.length })}
{intl.formatMessage(i18n.availableExtensions, {
count: sortedDisabledExtensions.length,
})}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
{sortedDisabledExtensions.map((extension) => (
<ExtensionItem
key={extension.name}
extension={extension}
onToggle={onToggle}
onConfigure={onConfigure}
onConfigure={configureHandler}
isStatic={isStatic}
/>
))}
@@ -111,8 +112,10 @@ export default function ExtensionList({
</div>
)}
{extensions.length === 0 && (
<div className="text-center text-text-secondary py-8">{intl.formatMessage(i18n.noExtensions)}</div>
{!hasVisibleExtensions && (
<div className="text-center text-text-secondary py-8">
{intl.formatMessage(i18n.noExtensions)}
</div>
)}
</div>
);
@@ -39,7 +39,7 @@ describe('Extension Utils', () => {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: true,
enabled: false,
timeout: 300,
envVars: [],
headers: [],
@@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: true,
enabled: false,
timeout: 300,
envVars: [],
headers: [],
+3 -6
View File
@@ -1058,17 +1058,14 @@
"extensionItem.configureExtension": {
"defaultMessage": "Configure {name} Extension"
},
"extensionItem.toggleExtension": {
"defaultMessage": "Toggle {name} extension On or Off"
},
"extensionList.availableExtensions": {
"defaultMessage": "Available Extensions ({count})"
"defaultMessage": "Available On Demand ({count})"
},
"extensionList.builtInExtension": {
"defaultMessage": "Built-in extension"
},
"extensionList.defaultExtensions": {
"defaultMessage": "Default Extensions ({count})"
"defaultMessage": "Active by Default ({count})"
},
"extensionList.noExtensions": {
"defaultMessage": "No extensions available"
@@ -1128,7 +1125,7 @@
"defaultMessage": "Browse extensions"
},
"extensionsView.defaultNote": {
"defaultMessage": "Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat."
"defaultMessage": "Extensions stay available here, and Goose can load them on demand during a chat."
},
"extensionsView.description": {
"defaultMessage": "These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search."
+1 -1
View File
@@ -38,6 +38,7 @@ export type AppView =
| "home"
| "chat"
| "skills"
| "extensions"
| "agents"
| "projects"
| "session-history";
@@ -51,7 +52,6 @@ const SETTINGS_SECTIONS = new Set<SectionId>([
"appearance",
"providers",
"compaction",
"extensions",
"voice",
"general",
"projects",
+3
View File
@@ -1,6 +1,7 @@
import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { ChatView } from "@/features/chat/ui/ChatView";
import { SkillsView } from "@/features/skills/ui/SkillsView";
import { ExtensionsView } from "@/features/extensions/ui/ExtensionsView";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView";
@@ -48,6 +49,8 @@ export function AppShellContent({
switch (activeView) {
case "skills":
return <SkillsView onStartChatWithSkill={onStartChatWithSkill} />;
case "extensions":
return <ExtensionsView />;
case "agents":
return <AgentsView />;
case "projects":
@@ -317,6 +317,44 @@ describe("artifactPathPolicy", () => {
expect(ranking?.primaryCandidate?.allowed).toBe(true);
});
it("uses semantic tool names instead of display titles for write detection", () => {
const result = buildArtifactsIndexForMessages(
[
{
id: "assistant-1",
role: "assistant",
created: Date.now(),
metadata: { userVisible: true, agentVisible: true },
content: [
{
type: "toolRequest",
id: "tool-1",
name: "Writing project summary",
toolName: "write_file",
arguments: { path: "/Users/test/project-a/summary.md" },
status: "completed",
},
{
type: "toolResponse",
id: "tool-1",
name: "Writing project summary",
result: "Created /Users/test/project-a/summary.md",
isError: false,
},
],
},
],
roots,
);
const ranking = result.byMessageId.get("assistant-1");
expect(ranking?.primaryToolCallId).toBe("tool-1");
expect(ranking?.primaryCandidate?.toolName).toBe("write_file");
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
"/Users/test/project-a/summary.md",
);
});
it("prefers an allowed candidate as primary when top-ranked candidate is blocked", () => {
const ranking = rankMessageToolArtifacts(
[
@@ -190,11 +190,12 @@ function extractToolCallsFromMessage(
for (const block of message.content) {
if (block.type === "toolRequest") {
const toolName = block.toolName ?? block.name;
if (!byId.has(block.id)) {
orderedIds.push(block.id);
byId.set(block.id, {
toolCallId: block.id,
toolName: block.name,
toolName,
args: toSafeRecord(block.arguments),
toolCallIndex,
});
@@ -202,7 +203,7 @@ function extractToolCallsFromMessage(
} else {
const existing = byId.get(block.id);
if (existing) {
existing.toolName = block.name || existing.toolName;
existing.toolName = toolName || existing.toolName;
existing.args = toSafeRecord(block.arguments);
}
}
@@ -1,5 +1,5 @@
import { IconStack2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
import type { Persona } from "@/shared/types/agents";
import type { ChatSkillDraft } from "../types";
import { ComposerChip } from "./ComposerChip";
@@ -40,7 +40,7 @@ export function ChatInputSelectionChips({
key={skill.id}
tone="skill"
label={skill.name}
leading={<IconStack2 className="size-3.5" />}
leading={<SkillIcon className="size-3.5" />}
onRemove={() => onRemoveSkill(skill.id)}
removeLabel={t("skill.clearSelected", {
skill: skill.name,
@@ -19,7 +19,6 @@ import type { ActiveWorkspace } from "../stores/chatSessionStore";
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
import { ChangesWidget } from "./widgets/ChangesWidget";
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
import { ExtensionsWidget } from "./widgets/ExtensionsWidget";
import { openPath } from "@tauri-apps/plugin-opener";
interface ContextPanelProps {
@@ -204,7 +203,6 @@ export function ContextPanel({
onOpenFile={handleOpenChangedFile}
/>
<ArtifactsWidget />
<ExtensionsWidget />
</div>
</TabsContent>
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, User, Zap } from "lucide-react";
import { Sparkles, User } from "lucide-react";
import { IconFile, IconFolder } from "@tabler/icons-react";
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
import { cn } from "@/shared/lib/cn";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import { PopoverContent } from "@/shared/ui/popover";
@@ -165,7 +166,7 @@ export function MentionAutocomplete({
onMouseEnter={() => setInternalIndex(globalIndex)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
<Zap className="h-3.5 w-3.5" />
<SkillIcon className="h-3.5 w-3.5" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{skill.name}</span>
@@ -1,4 +1,4 @@
import { IconStack2 } from "@tabler/icons-react";
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
import { cn } from "@/shared/lib/cn";
import type { MessageChip } from "@/shared/types/messages";
@@ -12,7 +12,7 @@ const messageChipClasses: Record<MessageChip["type"], string> = {
};
export function MessageMetadataChip({ chip }: { chip: MessageChip }) {
const Icon = chip.type === "skill" ? IconStack2 : null;
const Icon = chip.type === "skill" ? SkillIcon : null;
return (
<span
@@ -1,93 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPuzzle, IconSearch } from "@tabler/icons-react";
import { Input } from "@/shared/ui/input";
import { Widget } from "./Widget";
import { listExtensions } from "@/features/extensions/api/extensions";
import {
getDisplayName,
type ExtensionEntry,
} from "@/features/extensions/types";
export function ExtensionsWidget() {
const { t } = useTranslation("chat");
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const fetchEnabled = useCallback(() => {
listExtensions()
.then((all) => setExtensions(all.filter((e) => e.enabled)))
.catch(() => setExtensions([]));
}, []);
useEffect(() => {
fetchEnabled();
const handleVisibility = () => {
if (document.visibilityState === "visible") fetchEnabled();
};
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("focus", fetchEnabled);
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("focus", fetchEnabled);
};
}, [fetchEnabled]);
const filtered = useMemo(() => {
if (!searchTerm) return extensions;
const q = searchTerm.toLowerCase();
return extensions.filter((ext) => {
const name = getDisplayName(ext).toLowerCase();
return (
name.includes(q) || (ext.description ?? "").toLowerCase().includes(q)
);
});
}, [extensions, searchTerm]);
return (
<Widget
title={t("contextPanel.widgets.extensions")}
icon={<IconPuzzle className="size-3.5" />}
flush
>
{extensions.length === 0 ? (
<p className="px-3 py-2.5 text-xs text-foreground-subtle">
{t("contextPanel.empty.noExtensions")}
</p>
) : (
<div>
<div className="border-b border-border px-3 py-1.5">
<div className="flex items-center gap-1.5 text-foreground-subtle">
<IconSearch className="size-3" />
<Input
variant="ghost"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t("contextPanel.widgets.searchExtensions")}
className="text-xs"
/>
</div>
</div>
<div className="max-h-40 overflow-y-auto px-3 py-2">
{filtered.length === 0 ? (
<p className="py-1 text-xs text-foreground-subtle">
{t("contextPanel.empty.noMatchingExtensions")}
</p>
) : (
<div className="space-y-2">
{filtered.map((ext) => (
<div key={ext.config_key} className="flex items-center gap-2">
<span className="size-1.5 shrink-0 rounded-full bg-green-500" />
<span className="truncate text-xs">
{getDisplayName(ext)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</Widget>
);
}
@@ -1,13 +1,6 @@
import { getClient } from "@/shared/api/acpConnection";
import type { ExtensionConfig, ExtensionEntry } from "../types";
export function nameToKey(name: string): string {
return name
.replace(/\s/g, "")
.replace(/[^a-zA-Z0-9_-]/g, "_")
.toLowerCase();
}
export async function listExtensions(): Promise<ExtensionEntry[]> {
const client = await getClient();
const response = await client.goose.GooseConfigExtensions({});
@@ -17,7 +10,7 @@ export async function listExtensions(): Promise<ExtensionEntry[]> {
export async function addExtension(
name: string,
extensionConfig: ExtensionConfig,
enabled: boolean,
enabled = false,
): Promise<void> {
const client = await getClient();
await client.goose.GooseConfigExtensionsAdd({
@@ -31,11 +24,3 @@ export async function removeExtension(configKey: string): Promise<void> {
const client = await getClient();
await client.goose.GooseConfigExtensionsRemove({ configKey });
}
export async function toggleExtension(
configKey: string,
enabled: boolean,
): Promise<void> {
const client = await getClient();
await client.goose.GooseConfigExtensionsToggle({ configKey, enabled });
}
@@ -0,0 +1,163 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { ExtensionEntry } from "../../types";
import { useExtensionModalForm } from "../useExtensionModalForm";
describe("useExtensionModalForm", () => {
it("builds trimmed stdio configs with args, env vars, and timeout", () => {
const { result } = renderHook(() => useExtensionModalForm());
act(() => {
result.current.setName(" GitHub MCP ");
result.current.setDescription("Issue tools");
result.current.setCmd(" npx ");
result.current.setArgs(" -y \n @modelcontextprotocol/server-github \n\n");
result.current.setTimeout("45");
result.current.updateEnvVar(0, "key", " GITHUB_TOKEN ");
result.current.updateEnvVar(0, "value", "secret");
});
act(() => {
result.current.addEnvVar();
});
act(() => {
result.current.updateEnvVar(1, "key", " ");
result.current.updateEnvVar(1, "value", "ignored");
});
expect(result.current.buildSubmitPayload()).toEqual({
name: "GitHub MCP",
config: {
type: "stdio",
name: "GitHub MCP",
description: "Issue tools",
cmd: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
envs: { GITHUB_TOKEN: "secret" },
timeout: 45,
},
});
});
it("builds streamable HTTP configs and falls back to the default timeout", () => {
const { result } = renderHook(() => useExtensionModalForm());
act(() => {
result.current.setType("streamable_http");
result.current.setName(" Context7 ");
result.current.setDescription("Docs");
result.current.setUri(" https://mcp.context7.com/mcp ");
result.current.setTimeout("");
});
expect(result.current.buildSubmitPayload()).toEqual({
name: "Context7",
config: {
type: "streamable_http",
name: "Context7",
description: "Docs",
uri: "https://mcp.context7.com/mcp",
timeout: 300,
},
});
});
it("preserves editable config fields without submitting entry fields", () => {
const extension: ExtensionEntry = {
type: "streamable_http",
name: "context7",
description: "Docs",
uri: "https://old.example/mcp",
env_keys: ["API_KEY"],
headers: { Authorization: "Bearer token" },
socket: "/tmp/mcp.sock",
config_key: "context7",
enabled: true,
timeout: 60,
};
const { result } = renderHook(() => useExtensionModalForm(extension));
act(() => {
result.current.setUri("https://new.example/mcp");
});
expect(result.current.buildSubmitPayload()?.config).toMatchObject({
type: "streamable_http",
name: "context7",
uri: "https://new.example/mcp",
env_keys: ["API_KEY"],
headers: { Authorization: "Bearer token" },
socket: "/tmp/mcp.sock",
timeout: 60,
});
expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty(
"config_key",
);
expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty(
"enabled",
);
});
it("keeps secret env keys visible and preserves them when unchanged", () => {
const extension: ExtensionEntry = {
type: "stdio",
name: "github",
description: "Issue tools",
cmd: "npx",
args: [],
envs: { LEGACY_TOKEN: "plain" },
env_keys: ["GITHUB_TOKEN"],
config_key: "github",
enabled: false,
};
const { result } = renderHook(() => useExtensionModalForm(extension));
expect(result.current.envVars).toMatchObject([
{ key: "LEGACY_TOKEN", value: "plain" },
{ key: "GITHUB_TOKEN", value: "" },
]);
expect(result.current.buildSubmitPayload()?.config).toMatchObject({
envs: { LEGACY_TOKEN: "plain" },
env_keys: ["GITHUB_TOKEN"],
});
});
it("rejects non-HTTP streamable HTTP URIs", () => {
const { result } = renderHook(() => useExtensionModalForm());
act(() => {
result.current.setType("streamable_http");
result.current.setName("Local file");
result.current.setUri("file:///tmp/mcp");
});
expect(result.current.canSubmit).toBe(false);
expect(result.current.buildSubmitPayload()).toBeNull();
});
it("does not coerce unsupported SSE extensions into HTTP configs", () => {
const extension: ExtensionEntry = {
type: "sse",
name: "legacy-sse",
description: "Legacy SSE endpoint",
uri: "https://old.example/sse",
config_key: "legacy-sse",
enabled: true,
};
const { result } = renderHook(() => useExtensionModalForm(extension));
expect(result.current.type).toBe("unsupported");
expect(result.current.canSubmit).toBe(false);
expect(result.current.buildSubmitPayload()).toBeNull();
});
it("returns null when required fields are missing", () => {
const { result } = renderHook(() => useExtensionModalForm());
act(() => {
result.current.setName("No command");
});
expect(result.current.canSubmit).toBe(false);
expect(result.current.buildSubmitPayload()).toBeNull();
});
});
@@ -0,0 +1,121 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ExtensionEntry } from "../../types";
import { useExtensionsSettings } from "../useExtensionsSettings";
const mocks = vi.hoisted(() => ({
addExtension: vi.fn(),
listExtensions: vi.fn(),
removeExtension: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("../../api/extensions", () => ({
addExtension: mocks.addExtension,
listExtensions: mocks.listExtensions,
removeExtension: mocks.removeExtension,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { error: mocks.toastError },
}));
const enabledExtension: ExtensionEntry = {
type: "stdio",
name: "github",
description: "Issue tracker",
cmd: "npx",
args: [],
config_key: "github",
enabled: true,
};
describe("useExtensionsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listExtensions.mockResolvedValue([enabledExtension]);
mocks.addExtension.mockResolvedValue(undefined);
mocks.removeExtension.mockResolvedValue(undefined);
});
it("preserves an edited extension's enabled flag", async () => {
const { result } = renderHook(() => useExtensionsSettings());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.handleConfigure(enabledExtension);
});
await act(async () => {
await result.current.handleSubmit("github", enabledExtension);
});
expect(mocks.addExtension).toHaveBeenCalledWith(
"github",
enabledExtension,
true,
);
});
it("saves new extensions as disabled catalog entries", async () => {
const { result } = renderHook(() => useExtensionsSettings());
const newExtension: ExtensionEntry = {
...enabledExtension,
name: "linear",
config_key: "linear",
};
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.handleSubmit("linear", newExtension);
});
expect(mocks.addExtension).toHaveBeenCalledWith(
"linear",
newExtension,
false,
);
});
it("does not delete the new extension when renamed old-key removal fails", async () => {
mocks.removeExtension.mockRejectedValueOnce(new Error("remove failed"));
const { result } = renderHook(() => useExtensionsSettings());
const renamedExtension: ExtensionEntry = {
...enabledExtension,
name: "linear",
config_key: "linear",
};
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.handleConfigure(enabledExtension);
});
await act(async () => {
await result.current.handleSubmit("linear", renamedExtension);
});
expect(mocks.addExtension).toHaveBeenCalledWith(
"linear",
renamedExtension,
true,
);
expect(mocks.removeExtension).toHaveBeenCalledTimes(1);
expect(mocks.removeExtension).toHaveBeenCalledWith("github");
expect(mocks.removeExtension).not.toHaveBeenCalledWith("linear");
expect(mocks.toastError).toHaveBeenCalledWith(
"extensions.errors.saveFailed",
);
});
});
@@ -0,0 +1,139 @@
import { useState } from "react";
import {
buildExtensionSubmitPayload,
canSubmitExtensionConfig,
parseExtensionEnvRows,
type ExtensionEnvRow,
type ExtensionModalType,
} from "../lib/extensionFormConfig";
import type { ExtensionConfig, ExtensionEntry } from "../types";
export type { ExtensionModalType };
export interface EnvVar extends ExtensionEnvRow {
id: number;
}
let nextEnvId = 0;
function newEmptyEnvVar(): EnvVar {
return { id: nextEnvId++, key: "", value: "" };
}
function withEnvIds(rows: ExtensionEnvRow[]): EnvVar[] {
return rows.length > 0
? rows.map((row) => ({ id: nextEnvId++, ...row }))
: [newEmptyEnvVar()];
}
function initialType(extension?: ExtensionEntry): ExtensionModalType {
if (!extension) return "stdio";
if (extension.type === "stdio" || extension.type === "streamable_http") {
return extension.type;
}
return "unsupported";
}
function initialEnvVars(extension?: ExtensionEntry): EnvVar[] {
if (extension?.type === "stdio")
return withEnvIds(
parseExtensionEnvRows(extension.envs, extension.env_keys),
);
if (extension?.type === "streamable_http")
return withEnvIds(
parseExtensionEnvRows(extension.envs, extension.env_keys),
);
return [newEmptyEnvVar()];
}
export function useExtensionModalForm(extension?: ExtensionEntry) {
const [name, setName] = useState(extension?.name ?? "");
const [type, setType] = useState<ExtensionModalType>(() =>
initialType(extension),
);
const [description, setDescription] = useState(extension?.description ?? "");
const [cmd, setCmd] = useState(
extension?.type === "stdio" ? extension.cmd : "",
);
const [args, setArgs] = useState(
extension?.type === "stdio" ? extension.args.join("\n") : "",
);
const [uri, setUri] = useState(
extension?.type === "streamable_http" ? extension.uri : "",
);
const [timeout, setTimeout] = useState(
String(
extension?.type === "stdio" || extension?.type === "streamable_http"
? (extension.timeout ?? 300)
: 300,
),
);
const [envVars, setEnvVars] = useState<EnvVar[]>(() =>
initialEnvVars(extension),
);
const canSubmit = canSubmitExtensionConfig({ type, name, cmd, uri });
const updateEnvVar = (
index: number,
field: "key" | "value",
value: string,
) => {
setEnvVars((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const addEnvVar = () => {
setEnvVars((prev) => [...prev, newEmptyEnvVar()]);
};
const removeEnvVar = (id: number) => {
setEnvVars((prev) => {
if (prev.length <= 1) return [newEmptyEnvVar()];
return prev.filter((v) => v.id !== id);
});
};
const buildSubmitPayload = (): {
name: string;
config: ExtensionConfig;
} | null => {
return buildExtensionSubmitPayload({
type,
name,
description,
cmd,
args,
uri,
timeout,
envVars,
extension,
});
};
return {
name,
setName,
type,
setType,
description,
setDescription,
cmd,
setCmd,
args,
setArgs,
uri,
setUri,
timeout,
setTimeout,
envVars,
canSubmit,
updateEnvVar,
addEnvVar,
removeEnvVar,
buildSubmitPayload,
};
}
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
addExtension,
listExtensions,
removeExtension,
} from "../api/extensions";
import { nameToKey } from "../lib/extensionKeys";
import type { ExtensionConfig, ExtensionEntry } from "../types";
type ExtensionModalMode = "add" | "edit" | null;
export function useExtensionsSettings() {
const { t } = useTranslation("settings");
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [modalMode, setModalMode] = useState<ExtensionModalMode>(null);
const [editingExtension, setEditingExtension] =
useState<ExtensionEntry | null>(null);
const fetchExtensions = useCallback(async () => {
setIsLoading(true);
try {
const result = await listExtensions();
setExtensions(result);
} catch {
setExtensions([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void fetchExtensions();
}, [fetchExtensions]);
const handleAdd = useCallback(() => {
setEditingExtension(null);
setModalMode("add");
}, []);
const handleConfigure = useCallback((extension: ExtensionEntry) => {
setEditingExtension(extension);
setModalMode("edit");
}, []);
const handleSubmit = useCallback(
async (name: string, config: ExtensionConfig) => {
try {
const newKey = nameToKey(name);
const isEdit = !!editingExtension;
const isAdd = !editingExtension;
const keyChanged = isEdit && editingExtension.config_key !== newKey;
if (
(isAdd || keyChanged) &&
extensions.some((extension) => extension.config_key === newKey)
) {
toast.error(t("extensions.errors.nameConflict", { name }));
return;
}
await addExtension(name, config, editingExtension?.enabled ?? false);
if (keyChanged) {
await removeExtension(editingExtension.config_key);
}
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch {
await fetchExtensions();
toast.error(t("extensions.errors.saveFailed"));
}
},
[editingExtension, extensions, fetchExtensions, t],
);
const handleDelete = useCallback(
async (configKey: string) => {
try {
await removeExtension(configKey);
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch (error) {
toast.error(t("extensions.errors.deleteFailed"));
throw error;
}
},
[fetchExtensions, t],
);
const handleModalClose = useCallback(() => {
setModalMode(null);
setEditingExtension(null);
}, []);
return {
extensions,
isLoading,
modalMode,
editingExtension,
handleAdd,
handleConfigure,
handleSubmit,
handleDelete,
handleModalClose,
};
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { ExtensionEntry } from "../../types";
import {
classifyExtension,
filterExtensions,
getExtensionCategoryCounts,
splitExtensionsByCategory,
} from "../extensionCategories";
function extension(
name: string,
type: ExtensionEntry["type"],
description = "",
): ExtensionEntry {
return {
type,
name,
description,
config_key: name,
enabled: true,
...(type === "stdio" ? { cmd: "npx", args: [] } : {}),
...(type === "streamable_http" ? { uri: "http://localhost:3000/mcp" } : {}),
} as ExtensionEntry;
}
const labelForCategory = (category: string) =>
category === "gooseCapabilities" ? "Goose capabilities" : "Apps & services";
describe("extension categories", () => {
it("classifies built-in and platform extensions as Goose capabilities", () => {
expect(classifyExtension(extension("developer", "builtin"))).toBe(
"gooseCapabilities",
);
expect(classifyExtension(extension("computer", "platform"))).toBe(
"gooseCapabilities",
);
expect(classifyExtension(extension("github", "stdio"))).toBe(
"appsServices",
);
});
it("filters by search text across name, description, and category label", () => {
const extensions = [
extension("github", "stdio", "Issue tracker"),
extension("developer", "builtin", "Code tools"),
];
expect(
filterExtensions({
extensions,
searchTerm: "issue",
activeFilter: "all",
getCategoryLabel: labelForCategory,
}).map((item) => item.name),
).toEqual(["github"]);
expect(
filterExtensions({
extensions,
searchTerm: "goose",
activeFilter: "all",
getCategoryLabel: labelForCategory,
}).map((item) => item.name),
).toEqual(["developer"]);
});
it("counts and splits extensions by category", () => {
const extensions = [
extension("developer", "builtin"),
extension("computer", "platform"),
extension("github", "stdio"),
];
expect(getExtensionCategoryCounts(extensions)).toEqual({
appsServices: 1,
gooseCapabilities: 2,
});
expect(splitExtensionsByCategory(extensions)).toMatchObject({
primaryExtensions: [{ name: "github" }],
gooseCapabilities: [{ name: "developer" }, { name: "computer" }],
});
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { ExtensionEntry } from "../../types";
import {
buildExtensionEnvConfig,
buildExtensionSubmitPayload,
canSubmitExtensionConfig,
parseExtensionEnvRows,
} from "../extensionFormConfig";
describe("extensionFormConfig", () => {
it("combines legacy envs and secret env keys without duplicates", () => {
expect(
parseExtensionEnvRows(
{ LEGACY_TOKEN: "plain", SHARED_TOKEN: "plain-shared" },
["GITHUB_TOKEN", "SHARED_TOKEN"],
),
).toEqual([
{ key: "LEGACY_TOKEN", value: "plain" },
{ key: "SHARED_TOKEN", value: "plain-shared" },
{ key: "GITHUB_TOKEN", value: "" },
]);
});
it("builds envs for populated values and env_keys for blank values", () => {
expect(
buildExtensionEnvConfig([
{ key: " GITHUB_TOKEN ", value: "secret" },
{ key: " API_KEY ", value: "" },
{ key: " ", value: "ignored" },
]),
).toEqual({
envs: { GITHUB_TOKEN: "secret" },
env_keys: ["API_KEY"],
});
});
it("validates streamable HTTP URLs by scheme", () => {
expect(
canSubmitExtensionConfig({
type: "streamable_http",
name: "Context7",
cmd: "",
uri: "https://mcp.context7.com/mcp",
}),
).toBe(true);
expect(
canSubmitExtensionConfig({
type: "streamable_http",
name: "Local file",
cmd: "",
uri: "file:///tmp/mcp",
}),
).toBe(false);
});
it("builds clean submit payloads while preserving streamable-only fields", () => {
const extension: ExtensionEntry = {
type: "streamable_http",
name: "context7",
description: "Docs",
uri: "https://old.example/mcp",
env_keys: ["API_KEY"],
headers: { Authorization: "Bearer token" },
socket: "/tmp/mcp.sock",
config_key: "context7",
enabled: true,
timeout: 60,
};
const payload = buildExtensionSubmitPayload({
type: "streamable_http",
name: " context7 ",
description: "Docs",
cmd: "",
args: "",
uri: " https://new.example/mcp ",
timeout: "90",
envVars: [{ key: "API_KEY", value: "" }],
extension,
});
expect(payload).toEqual({
name: "context7",
config: {
type: "streamable_http",
name: "context7",
description: "Docs",
uri: "https://new.example/mcp",
env_keys: ["API_KEY"],
headers: { Authorization: "Bearer token" },
socket: "/tmp/mcp.sock",
timeout: 90,
},
});
expect(payload?.config).not.toHaveProperty("config_key");
expect(payload?.config).not.toHaveProperty("enabled");
});
});
@@ -0,0 +1,85 @@
import type { ExtensionEntry } from "../types";
import { getDisplayName } from "../types";
export type ExtensionCategory = "appsServices" | "gooseCapabilities";
export type ExtensionFilter = "all" | ExtensionCategory;
export const EXTENSION_CATEGORIES: readonly ExtensionCategory[] = [
"appsServices",
"gooseCapabilities",
];
const GOOSE_CAPABILITY_TYPES = new Set(["builtin", "platform"]);
export function classifyExtension(
extension: ExtensionEntry,
): ExtensionCategory {
if (GOOSE_CAPABILITY_TYPES.has(extension.type)) {
return "gooseCapabilities";
}
return "appsServices";
}
export function compareExtensionsByName(
a: ExtensionEntry,
b: ExtensionEntry,
): number {
return getDisplayName(a).localeCompare(getDisplayName(b));
}
export function getExtensionCategoryCounts(
extensions: ExtensionEntry[],
): Record<ExtensionCategory, number> {
const counts: Record<ExtensionCategory, number> = {
appsServices: 0,
gooseCapabilities: 0,
};
for (const extension of extensions) {
counts[classifyExtension(extension)] += 1;
}
return counts;
}
export function filterExtensions(options: {
extensions: ExtensionEntry[];
searchTerm: string;
activeFilter: ExtensionFilter;
getCategoryLabel: (category: ExtensionCategory) => string;
}): ExtensionEntry[] {
const { extensions, searchTerm, activeFilter, getCategoryLabel } = options;
const query = searchTerm.toLowerCase();
return extensions
.filter((extension) => {
const category = classifyExtension(extension);
const matchesSearch =
!query ||
getDisplayName(extension).toLowerCase().includes(query) ||
extension.name.toLowerCase().includes(query) ||
(extension.description ?? "").toLowerCase().includes(query) ||
getCategoryLabel(category).toLowerCase().includes(query);
return (
matchesSearch && (activeFilter === "all" || category === activeFilter)
);
})
.sort(compareExtensionsByName);
}
export function splitExtensionsByCategory(extensions: ExtensionEntry[]): {
primaryExtensions: ExtensionEntry[];
gooseCapabilities: ExtensionEntry[];
} {
const primaryExtensions: ExtensionEntry[] = [];
const gooseCapabilities: ExtensionEntry[] = [];
for (const extension of extensions) {
if (classifyExtension(extension) === "gooseCapabilities") {
gooseCapabilities.push(extension);
} else {
primaryExtensions.push(extension);
}
}
return { primaryExtensions, gooseCapabilities };
}
@@ -0,0 +1,172 @@
import type {
ExtensionConfig,
ExtensionEntry,
StdioExtensionConfig,
StreamableHttpExtensionConfig,
} from "../types";
export type ExtensionModalType = "stdio" | "streamable_http" | "unsupported";
export interface ExtensionEnvRow {
key: string;
value: string;
}
interface ExtensionSubmitConfigInput {
type: ExtensionModalType;
name: string;
description: string;
cmd: string;
args: string;
uri: string;
timeout: string;
envVars: ExtensionEnvRow[];
extension?: ExtensionEntry;
}
type PreservedCommonFields = {
available_tools?: string[];
bundled?: boolean;
};
export function parseExtensionEnvRows(
envs?: Record<string, string>,
envKeys?: string[],
): ExtensionEnvRow[] {
const rows: ExtensionEnvRow[] = [];
const seenKeys = new Set<string>();
for (const [key, value] of Object.entries(envs ?? {})) {
rows.push({ key, value });
seenKeys.add(key);
}
for (const key of envKeys ?? []) {
if (seenKeys.has(key)) continue;
rows.push({ key, value: "" });
}
return rows;
}
export function buildExtensionEnvConfig(
vars: ExtensionEnvRow[],
): Pick<
StdioExtensionConfig | StreamableHttpExtensionConfig,
"envs" | "env_keys"
> {
const envs: Record<string, string> = {};
const envKeys: string[] = [];
for (const v of vars) {
const key = v.key.trim();
if (!key) continue;
if (v.value.trim().length > 0) envs[key] = v.value;
else envKeys.push(key);
}
return {
...(Object.keys(envs).length > 0 ? { envs } : {}),
...(envKeys.length > 0 ? { env_keys: envKeys } : {}),
};
}
export function isValidStreamableHttpUri(value: string): boolean {
try {
const url = new URL(value.trim());
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function canSubmitExtensionConfig(input: {
type: ExtensionModalType;
name: string;
cmd: string;
uri: string;
}): boolean {
return (
input.type !== "unsupported" &&
input.name.trim().length > 0 &&
(input.type === "stdio"
? input.cmd.trim().length > 0
: isValidStreamableHttpUri(input.uri))
);
}
function preservedCommonFields(
extension?: ExtensionEntry,
): PreservedCommonFields {
return {
...(extension &&
"available_tools" in extension &&
extension.available_tools?.length
? { available_tools: extension.available_tools }
: {}),
...(extension && "bundled" in extension && extension.bundled !== undefined
? { bundled: extension.bundled }
: {}),
};
}
export function buildExtensionSubmitPayload({
type,
name,
description,
cmd,
args,
uri,
timeout,
envVars,
extension,
}: ExtensionSubmitConfigInput): {
name: string;
config: ExtensionConfig;
} | null {
if (!canSubmitExtensionConfig({ type, name, cmd, uri })) return null;
const trimmedName = name.trim();
const envConfig = buildExtensionEnvConfig(envVars);
const timeoutNum = Number.parseInt(timeout, 10) || 300;
const commonFields = preservedCommonFields(extension);
if (type === "stdio") {
return {
name: trimmedName,
config: {
type: "stdio",
name: trimmedName,
description,
cmd: cmd.trim(),
args: args
.split("\n")
.map((arg) => arg.trim())
.filter(Boolean),
...envConfig,
timeout: timeoutNum,
...commonFields,
},
};
}
return {
name: trimmedName,
config: {
type: "streamable_http",
name: trimmedName,
description,
uri: uri.trim(),
...envConfig,
...(extension?.type === "streamable_http" && extension.headers
? { headers: extension.headers }
: {}),
...(extension?.type === "streamable_http" && extension.socket
? { socket: extension.socket }
: {}),
timeout: timeoutNum,
...commonFields,
},
};
}
@@ -0,0 +1,8 @@
export function nameToKey(name: string): string {
return name
.replace(/\s/g, "")
.replace(/[^a-zA-Z0-9_-]/g, "_")
.toLowerCase();
}
export const normalizeExtensionKey = nameToKey;
+41 -3
View File
@@ -21,6 +21,15 @@ export interface BuiltinExtensionConfig {
available_tools?: string[];
}
export interface PlatformExtensionConfig {
type: "platform";
name: string;
description: string;
display_name?: string;
bundled?: boolean;
available_tools?: string[];
}
export interface StreamableHttpExtensionConfig {
type: "streamable_http";
name: string;
@@ -30,6 +39,7 @@ export interface StreamableHttpExtensionConfig {
env_keys?: string[];
headers?: Record<string, string>;
timeout?: number;
socket?: string;
bundled?: boolean;
available_tools?: string[];
}
@@ -42,19 +52,47 @@ export interface SseExtensionConfig {
bundled?: boolean;
}
export interface FrontendExtensionConfig {
type: "frontend";
name: string;
description: string;
tools: unknown[];
frontend_tools?: unknown[];
instructions?: string;
bundled?: boolean;
available_tools?: string[];
}
export interface InlinePythonExtensionConfig {
type: "inline_python";
name: string;
description: string;
code: string;
timeout?: number;
dependencies?: string[];
available_tools?: string[];
}
export type ExtensionConfig =
| StdioExtensionConfig
| BuiltinExtensionConfig
| PlatformExtensionConfig
| StreamableHttpExtensionConfig
| SseExtensionConfig;
| SseExtensionConfig
| FrontendExtensionConfig
| InlinePythonExtensionConfig;
export type ExtensionEntry = ExtensionConfig & {
config_key: string;
enabled: boolean;
};
export function getDisplayName(ext: ExtensionEntry): string {
if (ext.type === "builtin" && ext.display_name) {
export function getDisplayName(ext: {
type: ExtensionConfig["type"];
name: string;
display_name?: string | null;
}): string {
if ((ext.type === "builtin" || ext.type === "platform") && ext.display_name) {
return ext.display_name;
}
return ext.name;
@@ -1,14 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Switch } from "@/shared/ui/switch";
import { getDisplayName, type ExtensionEntry } from "../types";
interface ExtensionItemProps {
extension: ExtensionEntry;
onToggle: (extension: ExtensionEntry) => Promise<void>;
onConfigure?: (extension: ExtensionEntry) => void;
className?: string;
}
function getSubtitle(ext: ExtensionEntry): string {
@@ -18,48 +17,35 @@ function getSubtitle(ext: ExtensionEntry): string {
return ext.type;
}
const EDITABLE_TYPES = new Set(["stdio", "streamable_http"]);
function isUserManagedExtension(ext: ExtensionEntry): boolean {
return (
(ext.type === "stdio" || ext.type === "streamable_http") && !ext.bundled
);
}
function isEditable(ext: ExtensionEntry): boolean {
return EDITABLE_TYPES.has(ext.type) && !ext.bundled;
return isUserManagedExtension(ext);
}
export function ExtensionItem({
extension,
onToggle,
onConfigure,
className,
}: ExtensionItemProps) {
const { t } = useTranslation("settings");
const [isToggling, setIsToggling] = useState(false);
const [visualEnabled, setVisualEnabled] = useState(extension.enabled);
const handleToggle = async () => {
if (isToggling) return;
setIsToggling(true);
setVisualEnabled(!extension.enabled);
try {
await onToggle(extension);
} catch {
setVisualEnabled(extension.enabled);
} finally {
setIsToggling(false);
}
};
const editable = isEditable(extension);
const checked = isToggling ? visualEnabled : extension.enabled;
const displayName = getDisplayName(extension);
return (
<div className="flex items-center justify-between gap-3 py-4">
<div
className={cn(
"flex min-h-20 items-center justify-between gap-3 border-b border-border-soft-divider py-4",
className,
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(`extensions.types.${extension.type}`, {
defaultValue: extension.type,
})}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{getSubtitle(extension)}
@@ -78,14 +64,6 @@ export function ExtensionItem({
<IconSettings className="size-4" />
</Button>
)}
<Switch
checked={checked}
onCheckedChange={handleToggle}
disabled={isToggling}
aria-label={t("extensions.toggle", {
name: displayName,
})}
/>
</div>
</div>
);
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Button } from "@/shared/ui/button";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import { Textarea } from "@/shared/ui/textarea";
@@ -19,49 +20,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import {
useExtensionModalForm,
type ExtensionModalType,
} from "../hooks/useExtensionModalForm";
import type { ExtensionConfig, ExtensionEntry } from "../types";
type ExtensionType = "stdio" | "streamable_http";
interface ExtensionModalProps {
extension?: ExtensionEntry;
onSubmit: (
name: string,
config: ExtensionConfig,
enabled: boolean,
) => Promise<void>;
onSubmit: (name: string, config: ExtensionConfig) => Promise<void>;
onDelete?: (configKey: string) => Promise<void>;
onClose: () => void;
}
interface EnvVar {
id: number;
key: string;
value: string;
}
let nextEnvId = 0;
function parseEnvVars(envs?: Record<string, string>): EnvVar[] {
if (!envs || Object.keys(envs).length === 0)
return [{ id: nextEnvId++, key: "", value: "" }];
return Object.entries(envs).map(([key, value]) => ({
id: nextEnvId++,
key,
value,
}));
}
function buildEnvVars(vars: EnvVar[]): Record<string, string> {
const result: Record<string, string> = {};
for (const v of vars) {
if (v.key.trim()) {
result[v.key.trim()] = v.value;
}
}
return result;
}
export function ExtensionModal({
extension,
onSubmit,
@@ -71,293 +42,238 @@ export function ExtensionModal({
const { t } = useTranslation("settings");
const isEdit = !!extension;
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(extension?.name ?? "");
const [type, setType] = useState<ExtensionType>(
extension?.type === "streamable_http" || extension?.type === "sse"
? "streamable_http"
: "stdio",
);
const [description, setDescription] = useState(extension?.description ?? "");
const [cmd, setCmd] = useState(
extension?.type === "stdio" ? extension.cmd : "",
);
const [args, setArgs] = useState(
extension?.type === "stdio" ? extension.args.join("\n") : "",
);
const [uri, setUri] = useState(
extension?.type === "streamable_http"
? extension.uri
: extension?.type === "sse"
? (extension.uri ?? "")
: "",
);
const [timeout, setTimeout] = useState(
String(
extension?.type === "stdio" || extension?.type === "streamable_http"
? (extension.timeout ?? 300)
: 300,
),
);
const [envVars, setEnvVars] = useState<EnvVar[]>(() => {
if (extension?.type === "stdio") return parseEnvVars(extension.envs);
if (extension?.type === "streamable_http")
return parseEnvVars(extension.envs);
return [{ id: nextEnvId++, key: "", value: "" }];
});
const canSubmit =
name.trim().length > 0 &&
(type === "stdio" ? cmd.trim().length > 0 : uri.trim().length > 0);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const form = useExtensionModalForm(extension);
const handleSubmit = async () => {
if (!canSubmit || isSaving) return;
if (!form.canSubmit || isSaving) return;
setIsSaving(true);
try {
const trimmedName = name.trim();
const envs = buildEnvVars(envVars);
const timeoutNum = Number.parseInt(timeout, 10) || 300;
let config: ExtensionConfig;
if (type === "stdio") {
config = {
...(extension?.type === "stdio" ? extension : {}),
type: "stdio",
name: trimmedName,
description,
cmd: cmd.trim(),
args: args
.split("\n")
.map((a) => a.trim())
.filter(Boolean),
envs,
timeout: timeoutNum,
};
} else {
if (!uri.trim()) return;
config = {
...(extension?.type === "streamable_http" ? extension : {}),
type: "streamable_http",
name: trimmedName,
description,
uri: uri.trim(),
envs,
timeout: timeoutNum,
};
}
await onSubmit(trimmedName, config, extension?.enabled ?? true);
const payload = form.buildSubmitPayload();
if (!payload) return;
await onSubmit(payload.name, payload.config);
} finally {
setIsSaving(false);
}
};
const updateEnvVar = (index: number, field: "key" | "value", val: string) => {
setEnvVars((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: val };
return next;
});
};
const handleConfirmDelete = async () => {
if (!extension || !onDelete || isDeleting) return;
const addEnvVar = () => {
setEnvVars((prev) => [...prev, { id: nextEnvId++, key: "", value: "" }]);
};
const removeEnvVar = (id: number) => {
setEnvVars((prev) => {
if (prev.length <= 1) return [{ id: nextEnvId++, key: "", value: "" }];
return prev.filter((v) => v.id !== id);
});
setIsDeleting(true);
try {
await onDelete(extension.config_key);
setIsDeleteDialogOpen(false);
} finally {
setIsDeleting(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit
? t("extensions.editExtension")
: t("extensions.addExtension")}
</DialogTitle>
</DialogHeader>
<>
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit
? t("extensions.editExtension")
: t("extensions.addExtension")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
<Input
id="ext-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("extensions.fields.namePlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
<Select
value={type}
onValueChange={(v) => setType(v as ExtensionType)}
>
<SelectTrigger id="ext-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">
{t("extensions.types.stdio")}
</SelectItem>
<SelectItem value="streamable_http">
{t("extensions.types.streamable_http")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-desc">
{t("extensions.fields.description")}
</Label>
<Input
id="ext-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("extensions.fields.descriptionPlaceholder")}
/>
</div>
{type === "stdio" && (
<>
<div className="space-y-1.5">
<Label htmlFor="ext-cmd">
{t("extensions.fields.command")}
</Label>
<Input
id="ext-cmd"
value={cmd}
onChange={(e) => setCmd(e.target.value)}
placeholder={t("extensions.fields.commandPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-args">
{t("extensions.fields.arguments")}
</Label>
<Textarea
id="ext-args"
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={t("extensions.fields.argumentsPlaceholder")}
rows={3}
/>
</div>
</>
)}
{type === "streamable_http" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
<Input
id="ext-uri"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder={t("extensions.fields.urlPlaceholder")}
id="ext-name"
value={form.name}
onChange={(e) => form.setName(e.target.value)}
placeholder={t("extensions.fields.namePlaceholder")}
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ext-timeout">
{t("extensions.fields.timeout")}
</Label>
<Input
id="ext-timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
min={1}
/>
<div className="space-y-1.5">
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
<Select
value={form.type}
onValueChange={(value) =>
form.setType(value as ExtensionModalType)
}
>
<SelectTrigger id="ext-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">
{t("extensions.types.stdio")}
</SelectItem>
<SelectItem value="streamable_http">
{t("extensions.types.streamable_http")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-desc">
{t("extensions.fields.description")}
</Label>
<Input
id="ext-desc"
value={form.description}
onChange={(e) => form.setDescription(e.target.value)}
placeholder={t("extensions.fields.descriptionPlaceholder")}
/>
</div>
{form.type === "stdio" && (
<>
<div className="space-y-1.5">
<Label htmlFor="ext-cmd">
{t("extensions.fields.command")}
</Label>
<Input
id="ext-cmd"
value={form.cmd}
onChange={(e) => form.setCmd(e.target.value)}
placeholder={t("extensions.fields.commandPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-args">
{t("extensions.fields.arguments")}
</Label>
<Textarea
id="ext-args"
value={form.args}
onChange={(e) => form.setArgs(e.target.value)}
placeholder={t("extensions.fields.argumentsPlaceholder")}
rows={3}
/>
</div>
</>
)}
{form.type === "streamable_http" && (
<div className="space-y-1.5">
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
<Input
id="ext-uri"
value={form.uri}
onChange={(e) => form.setUri(e.target.value)}
placeholder={t("extensions.fields.urlPlaceholder")}
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ext-timeout">
{t("extensions.fields.timeout")}
</Label>
<Input
id="ext-timeout"
type="number"
value={form.timeout}
onChange={(e) => form.setTimeout(e.target.value)}
min={1}
/>
</div>
<div className="space-y-1.5">
<Label>{t("extensions.fields.envVars")}</Label>
<div className="space-y-2">
{form.envVars.map((env, i) => (
<div key={env.id} className="flex items-center gap-2">
<Input
value={env.key}
onChange={(e) =>
form.updateEnvVar(i, "key", e.target.value)
}
placeholder={t("extensions.fields.envKeyPlaceholder")}
className="flex-1"
/>
<Input
value={env.value}
onChange={(e) =>
form.updateEnvVar(i, "value", e.target.value)
}
placeholder={t("extensions.fields.envValuePlaceholder")}
className="flex-1"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={() => form.removeEnvVar(env.id)}
className="shrink-0 hover:text-destructive"
aria-label={t("extensions.fields.removeEnvVar")}
>
<IconTrash className="size-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={form.addEnvVar}
>
<IconPlus className="size-3.5" />
{t("extensions.fields.addEnvVar")}
</Button>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label>{t("extensions.fields.envVars")}</Label>
<div className="space-y-2">
{envVars.map((env, i) => (
<div key={env.id} className="flex items-center gap-2">
<Input
value={env.key}
onChange={(e) => updateEnvVar(i, "key", e.target.value)}
placeholder={t("extensions.fields.envKeyPlaceholder")}
className="flex-1"
/>
<Input
value={env.value}
onChange={(e) => updateEnvVar(i, "value", e.target.value)}
placeholder={t("extensions.fields.envValuePlaceholder")}
className="flex-1"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeEnvVar(env.id)}
className="shrink-0 hover:text-destructive"
aria-label={t("extensions.fields.removeEnvVar")}
>
<IconTrash className="size-3.5" />
</Button>
</div>
))}
<DialogFooter>
{isEdit && onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={addEnvVar}
onClick={() => setIsDeleteDialogOpen(true)}
disabled={isSaving || isDeleting}
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
>
<IconPlus className="size-3.5" />
{t("extensions.fields.addEnvVar")}
<IconTrash className="size-4" />
{t("extensions.deleteExtension")}
</Button>
</div>
</div>
</div>
<DialogFooter>
{isEdit && onDelete && (
)}
<Button
type="button"
variant="ghost"
onClick={async () => {
setIsSaving(true);
try {
await onDelete(extension.config_key);
} finally {
setIsSaving(false);
}
}}
variant="outline"
onClick={onClose}
disabled={isSaving}
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
>
<IconTrash className="size-4" />
{t("extensions.deleteExtension")}
{t("extensions.cancel")}
</Button>
)}
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{t("extensions.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSaving}
>
{t("extensions.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
type="button"
onClick={handleSubmit}
disabled={!form.canSubmit || isSaving}
>
{t("extensions.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isEdit && onDelete && (
<ConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title={t("extensions.deleteConfirmation.title", { name: form.name })}
description={t("extensions.deleteConfirmation.description")}
cancelLabel={t("extensions.cancel")}
confirmLabel={t("extensions.deleteConfirmation.confirm")}
loadingLabel={t("extensions.deleteConfirmation.deleting")}
isLoading={isDeleting}
overlayClassName="z-[70]"
positionerClassName="z-[71]"
onConfirm={handleConfirmDelete}
onConfirmError={() => undefined}
/>
)}
</>
);
}
@@ -1,143 +1,115 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IconPlus } from "@tabler/icons-react";
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
import { Button } from "@/shared/ui/button";
import { SearchBar } from "@/shared/ui/SearchBar";
import { FilterRow } from "@/shared/ui/page-shell";
import { SettingsPage } from "@/shared/ui/SettingsPage";
import { useExtensionsSettings } from "../hooks/useExtensionsSettings";
import {
listExtensions,
addExtension,
removeExtension,
toggleExtension,
nameToKey,
} from "../api/extensions";
import {
getDisplayName,
type ExtensionConfig,
type ExtensionEntry,
} from "../types";
EXTENSION_CATEGORIES,
filterExtensions,
getExtensionCategoryCounts,
splitExtensionsByCategory,
type ExtensionFilter,
} from "../lib/extensionCategories";
import type { ExtensionEntry } from "../types";
import { ExtensionItem } from "./ExtensionItem";
import { ExtensionModal } from "./ExtensionModal";
import { SettingsPage } from "@/shared/ui/SettingsPage";
function FilterButton({
active,
children,
onClick,
}: {
active: boolean;
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button
type="button"
size="xs"
variant={active ? "default" : "outline-flat"}
onClick={onClick}
>
{children}
</Button>
);
}
export function ExtensionsSettings() {
const { t } = useTranslation("settings");
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [modalMode, setModalMode] = useState<"add" | "edit" | null>(null);
const [editingExtension, setEditingExtension] =
useState<ExtensionEntry | null>(null);
const {
extensions,
isLoading,
modalMode,
editingExtension,
handleAdd,
handleConfigure,
handleSubmit,
handleDelete,
handleModalClose,
} = useExtensionsSettings();
const [searchTerm, setSearchTerm] = useState("");
const [activeFilter, setActiveFilter] = useState<ExtensionFilter>("all");
const [showGooseCapabilities, setShowGooseCapabilities] = useState(false);
const fetchExtensions = useCallback(async () => {
try {
const result = await listExtensions();
setExtensions(result);
} catch {
setExtensions([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void fetchExtensions();
}, [fetchExtensions]);
const matchesSearch = useCallback(
(ext: ExtensionEntry) => {
if (!searchTerm) return true;
const q = searchTerm.toLowerCase();
return (
getDisplayName(ext).toLowerCase().includes(q) ||
ext.name.toLowerCase().includes(q) ||
(ext.description ?? "").toLowerCase().includes(q) ||
ext.type.toLowerCase().includes(q)
);
},
[searchTerm],
const filteredExtensions = useMemo(
() =>
filterExtensions({
extensions,
searchTerm,
activeFilter,
getCategoryLabel: (category) => t(`extensions.categories.${category}`),
}),
[activeFilter, extensions, searchTerm, t],
);
const sorted = useMemo(() => {
return [...extensions].sort((a, b) => {
if (a.type === "builtin" && b.type !== "builtin") return -1;
if (a.type !== "builtin" && b.type === "builtin") return 1;
const aBundled = a.bundled === true;
const bBundled = b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
return a.name.localeCompare(b.name);
});
}, [extensions]);
const enabled = useMemo(
() => sorted.filter((e) => e.enabled && matchesSearch(e)),
[sorted, matchesSearch],
);
const available = useMemo(
() => sorted.filter((e) => !e.enabled && matchesSearch(e)),
[sorted, matchesSearch],
const { primaryExtensions, gooseCapabilities } = useMemo(
() => splitExtensionsByCategory(filteredExtensions),
[filteredExtensions],
);
const handleToggle = async (ext: ExtensionEntry) => {
try {
await toggleExtension(ext.config_key, !ext.enabled);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.toggleFailed"));
}
};
const visibleExtensions =
activeFilter === "gooseCapabilities"
? gooseCapabilities
: [...primaryExtensions, ...gooseCapabilities];
const hasSearch = searchTerm.trim().length > 0;
const shouldShowGooseCapabilities =
activeFilter === "gooseCapabilities" || showGooseCapabilities || hasSearch;
const showGooseCapabilitiesToggle =
activeFilter !== "gooseCapabilities" &&
!hasSearch &&
gooseCapabilities.length > 0;
const handleConfigure = (ext: ExtensionEntry) => {
setEditingExtension(ext);
setModalMode("edit");
};
const categoryCounts = useMemo(
() => getExtensionCategoryCounts(extensions),
[extensions],
);
const handleSubmit = async (
name: string,
config: ExtensionConfig,
extensionEnabled: boolean,
const renderSection = (
title: string,
sectionExtensions: ExtensionEntry[],
showTitle = true,
) => {
try {
const newKey = nameToKey(name);
const isEdit = !!editingExtension;
const isAdd = !editingExtension;
const keyChanged = isEdit && editingExtension.config_key !== newKey;
if (
(isAdd || keyChanged) &&
extensions.some((e) => e.config_key === newKey)
) {
toast.error(t("extensions.errors.nameConflict", { name }));
return;
}
await addExtension(name, config, extensionEnabled);
if (keyChanged) {
await removeExtension(editingExtension.config_key);
}
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.saveFailed"));
}
};
const handleDelete = async (configKey: string) => {
try {
await removeExtension(configKey);
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.deleteFailed"));
}
};
const handleModalClose = () => {
setModalMode(null);
setEditingExtension(null);
if (sectionExtensions.length === 0) return null;
return (
<section className="space-y-3">
{showTitle ? (
<h4 className="text-sm font-normal text-foreground">{title}</h4>
) : null}
<div className="grid gap-x-12 sm:grid-cols-2">
{sectionExtensions.map((ext) => (
<ExtensionItem
key={ext.config_key}
extension={ext}
onConfigure={handleConfigure}
/>
))}
</div>
</section>
);
};
return (
@@ -146,76 +118,98 @@ export function ExtensionsSettings() {
actions={
<Button
type="button"
variant="outline"
size="xxs"
onClick={() => {
setEditingExtension(null);
setModalMode("add");
}}
variant="outline-flat"
size="xs"
onClick={handleAdd}
>
<IconPlus className="size-4" />
<IconPlus className="size-3.5" />
{t("extensions.addExtension")}
</Button>
}
controls={
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder={t("extensions.search")}
aria-label={t("extensions.search")}
size="compact"
/>
<div className="space-y-3">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder={t("extensions.search")}
aria-label={t("extensions.search")}
size="compact"
/>
<FilterRow>
<FilterButton
active={activeFilter === "all"}
onClick={() => setActiveFilter("all")}
>
{t("extensions.filters.all")}
</FilterButton>
{EXTENSION_CATEGORIES.map((category) =>
categoryCounts[category] > 0 ? (
<FilterButton
key={category}
active={activeFilter === category}
onClick={() => setActiveFilter(category)}
>
{t(`extensions.categories.${category}`)}
</FilterButton>
) : null,
)}
</FilterRow>
</div>
}
>
{isLoading ? (
<div className="divide-y divide-border">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 animate-pulse bg-muted/30" />
<div className="grid gap-x-12 sm:grid-cols-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="h-20 animate-pulse border-b border-border-soft-divider py-4"
>
<div className="h-4 w-2/5 rounded bg-muted/50" />
<div className="mt-2 h-3 w-3/5 rounded bg-muted/40" />
</div>
))}
</div>
) : extensions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("extensions.empty")}</p>
) : enabled.length === 0 && available.length === 0 && searchTerm ? (
) : visibleExtensions.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("extensions.noResults")}
</p>
) : (
<div className="space-y-4">
{enabled.length > 0 && (
<div>
<h4 className="text-sm font-medium">
{t("extensions.enabledCount", { count: enabled.length })}
</h4>
<div className="divide-y divide-border">
{enabled.map((ext) => (
<ExtensionItem
key={ext.config_key}
extension={ext}
onToggle={handleToggle}
onConfigure={handleConfigure}
/>
))}
</div>
</div>
)}
<div className="space-y-8">
{activeFilter !== "gooseCapabilities"
? renderSection(
t("extensions.sections.extensions"),
primaryExtensions,
false,
)
: null}
{available.length > 0 && (
<div>
<h4 className="text-sm font-medium text-muted-foreground">
{t("extensions.availableCount", { count: available.length })}
</h4>
<div className="divide-y divide-border">
{available.map((ext) => (
<ExtensionItem
key={ext.config_key}
extension={ext}
onToggle={handleToggle}
onConfigure={handleConfigure}
/>
))}
</div>
</div>
)}
{shouldShowGooseCapabilities
? renderSection(
t("extensions.sections.gooseCapabilities"),
gooseCapabilities,
)
: null}
{showGooseCapabilitiesToggle ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowGooseCapabilities((current) => !current)}
className="w-full text-muted-foreground"
>
{showGooseCapabilities
? t("extensions.hideGooseCapabilities")
: t("extensions.showGooseCapabilities", {
count: gooseCapabilities.length,
})}
{!showGooseCapabilities ? (
<IconChevronDown className="size-3" />
) : null}
</Button>
) : null}
</div>
)}
@@ -0,0 +1,10 @@
import { PageShell } from "@/shared/ui/page-shell";
import { ExtensionsSettings } from "./ExtensionsSettings";
export function ExtensionsView() {
return (
<PageShell>
<ExtensionsSettings />
</PageShell>
);
}
@@ -0,0 +1,87 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ExtensionEntry } from "../../types";
import { ExtensionModal } from "../ExtensionModal";
const extension: ExtensionEntry = {
type: "stdio",
name: "github",
description: "Issue tracker",
cmd: "npx",
args: [],
config_key: "github",
enabled: true,
};
describe("ExtensionModal", () => {
it("confirms before deleting an extension", async () => {
const user = userEvent.setup();
const handleDelete = vi.fn().mockResolvedValue(undefined);
render(
<ExtensionModal
extension={extension}
onSubmit={vi.fn()}
onDelete={handleDelete}
onClose={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Delete Extension" }));
expect(handleDelete).not.toHaveBeenCalled();
const confirmation = screen.getByRole("dialog", {
name: 'Delete "github" permanently?',
});
expect(
within(confirmation).getByText('Delete "github" permanently?'),
).toBeInTheDocument();
await user.click(
within(confirmation).getByRole("button", { name: "Delete Extension" }),
);
expect(handleDelete).toHaveBeenCalledWith("github");
});
it("dismisses the delete confirmation when clicking outside it", async () => {
const user = userEvent.setup();
const handleDelete = vi.fn().mockResolvedValue(undefined);
const handleClose = vi.fn();
render(
<ExtensionModal
extension={extension}
onSubmit={vi.fn()}
onDelete={handleDelete}
onClose={handleClose}
/>,
);
await user.click(screen.getByRole("button", { name: "Delete Extension" }));
expect(
screen.getByRole("dialog", {
name: 'Delete "github" permanently?',
}),
).toBeInTheDocument();
const overlays = document.querySelectorAll('[data-slot$="dialog-overlay"]');
expect(overlays).toHaveLength(2);
expect(overlays[overlays.length - 1]).toHaveClass("z-[70]");
await user.click(overlays[overlays.length - 1] as HTMLElement);
await waitFor(() => {
expect(
screen.queryByRole("dialog", {
name: 'Delete "github" permanently?',
}),
).not.toBeInTheDocument();
});
expect(handleDelete).not.toHaveBeenCalled();
expect(handleClose).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: "Edit Extension" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ExtensionEntry } from "../../types";
import { ExtensionsSettings } from "../ExtensionsSettings";
const mockUseExtensionsSettings = vi.fn();
vi.mock("@/features/extensions/hooks/useExtensionsSettings", () => ({
useExtensionsSettings: () => mockUseExtensionsSettings(),
}));
const extensions: ExtensionEntry[] = [
{
type: "stdio",
name: "github",
description: "Issue tracker",
cmd: "npx",
args: [],
config_key: "github",
enabled: true,
},
{
type: "builtin",
name: "developer",
display_name: "Developer",
description: "Code tools",
config_key: "developer",
enabled: true,
},
{
type: "platform",
name: "summarize",
display_name: "Summarize",
description: "Summarize files",
config_key: "summarize",
enabled: false,
},
];
describe("ExtensionsSettings", () => {
beforeEach(() => {
mockUseExtensionsSettings.mockReturnValue({
extensions,
isLoading: false,
modalMode: null,
editingExtension: null,
handleAdd: vi.fn(),
handleConfigure: vi.fn(),
handleSubmit: vi.fn(),
handleDelete: vi.fn(),
handleModalClose: vi.fn(),
});
});
it("reveals matching Goose capabilities while searching", async () => {
const user = userEvent.setup();
render(<ExtensionsSettings />);
expect(screen.queryByText("Developer")).not.toBeInTheDocument();
await user.type(screen.getByRole("searchbox"), "developer");
expect(screen.getByText("Developer")).toBeInTheDocument();
expect(
screen.queryByRole("button", {
name: /show .*built-in goose capabilities/i,
}),
).not.toBeInTheDocument();
});
it("does not show global enable toggles", async () => {
const user = userEvent.setup();
render(<ExtensionsSettings />);
expect(
screen.queryByRole("switch", { name: /disable github/i }),
).not.toBeInTheDocument();
await user.type(screen.getByRole("searchbox"), "summarize");
expect(
screen.queryByRole("switch", { name: /enable summarize/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("switch", { name: /enable developer/i }),
).not.toBeInTheDocument();
});
});
@@ -13,11 +13,10 @@ import {
Stethoscope,
X,
} from "lucide-react";
import { IconPlug, IconPuzzle } from "@tabler/icons-react";
import { IconPlug } from "@tabler/icons-react";
import { AppearanceSettings } from "./AppearanceSettings";
import { DoctorSettings } from "./DoctorSettings";
import { ProvidersSettings } from "./ProvidersSettings";
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
import { VoiceInputSettings } from "./VoiceInputSettings";
import { GeneralSettings } from "./GeneralSettings";
import { CompactionSettings } from "./CompactionSettings";
@@ -29,7 +28,6 @@ const NAV_ITEMS = [
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
{ id: "compaction", labelKey: "nav.compaction", icon: Minimize2 },
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
{ id: "voice", labelKey: "nav.voice", icon: Mic },
{ id: "general", labelKey: "nav.general", icon: Settings2 },
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
@@ -170,7 +168,6 @@ export function SettingsModal({
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "providers" && <ProvidersSettings />}
{activeSection === "compaction" && <CompactionSettings />}
{activeSection === "extensions" && <ExtensionsSettings />}
{activeSection === "voice" && <VoiceInputSettings />}
{activeSection === "doctor" && <DoctorSettings />}
{activeSection === "general" && <GeneralSettings />}
@@ -5,11 +5,12 @@ import {
IconHome,
IconLayoutSidebar,
IconLayoutSidebarFilled,
IconApps,
IconRobotFace,
IconSearch,
IconSettings,
IconStack,
} from "@tabler/icons-react";
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
import { cn } from "@/shared/lib/cn";
@@ -131,7 +132,12 @@ export function Sidebar({
icon: typeof IconRobotFace;
}[] = [
{ id: "agents", label: t("navigation.agents"), icon: IconRobotFace },
{ id: "skills", label: t("navigation.skills"), icon: IconStack },
{ id: "skills", label: t("navigation.skills"), icon: SkillIcon },
{
id: "extensions",
label: t("navigation.extensions"),
icon: IconApps,
},
{
id: "session-history",
label: t("navigation.sessionHistory"),
@@ -461,7 +467,7 @@ export function Sidebar({
size={collapsed ? "icon-sm" : "default"}
onClick={onSettingsClick}
className={cn(
"h-10 w-full rounded-md bg-transparent text-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
"h-10 w-full rounded-md bg-transparent text-muted-foreground/85 hover:bg-transparent hover:text-foreground active:bg-transparent",
collapsed
? "justify-center p-3"
: "justify-start gap-2.5 px-3 py-2.5",
@@ -1,6 +1,6 @@
import { useCallback, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { IconMessage } from "@tabler/icons-react";
import { IconEdit, IconMessage } from "@tabler/icons-react";
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -96,16 +96,16 @@ export function SidebarRecentsSection({
<Button
type="button"
variant="ghost"
size="xs"
size="icon-xs"
onClick={onNewChat}
aria-label={t("actions.newChat")}
title={t("actions.newChat")}
className={cn(
"mr-1 h-6 flex-shrink-0 rounded-full bg-muted px-2 text-[11px] text-foreground opacity-0 transition-opacity duration-150 ease-out hover:bg-muted/80 hover:text-foreground",
"pointer-events-none group-hover/chats-header:pointer-events-auto group-hover/chats-header:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
"mr-1 size-6 flex-shrink-0 rounded-md",
"opacity-0 pointer-events-none group-hover/chats-header:opacity-100 group-hover/chats-header:pointer-events-auto group-focus-within/chats-header:opacity-100 group-focus-within/chats-header:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto",
)}
>
{t("actions.newChat")}
<IconEdit className="size-4" />
</Button>
)}
@@ -0,0 +1,3 @@
import { IconBook } from "@tabler/icons-react";
export const SkillIcon = IconBook;
@@ -125,6 +125,8 @@ describe("acpNotificationHandler", () => {
type: "toolRequest",
id: "tool-1",
name: "mcp_app_bench__inspect_host_info",
toolName: "mcp_app_bench__inspect_host_info",
extensionName: "mcp_app_bench",
status: "completed",
});
expect(message.content[1]).toMatchObject({
@@ -234,6 +236,11 @@ describe("acpNotificationHandler", () => {
"mcpApp",
"text",
]);
expect(buffer?.[1]?.content[0]).toMatchObject({
type: "toolRequest",
toolName: "mcp_app_bench__inspect_host_info",
extensionName: "mcp_app_bench",
});
expect(buffer?.[1]?.content[2]).toMatchObject({
type: "mcpApp",
id: "tool-1",
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearReplayBuffer,
getReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
clearMessageTracking,
handleSessionNotification,
setActiveMessageId,
} from "../acpNotificationHandler";
import { registerSession } from "../acpSessionTracker";
describe("ACP tool call status handling", () => {
beforeEach(() => {
clearMessageTracking();
clearReplayBuffer("replay-failed-tool-session");
clearReplayBuffer("goose-session");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
scrollTargetMessageBySession: {},
});
});
it("marks failed replay tool updates as errors", async () => {
const replaySessionId = "replay-failed-tool-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "shell",
},
} as never);
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Command failed.",
},
},
],
},
} as never);
const assistant = getReplayBuffer(replaySessionId)?.[0];
expect(assistant?.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
status: "error",
});
expect(assistant?.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
isError: true,
result: "Command failed.",
});
});
it("marks failed live tool updates as errors", async () => {
registerSession(
"local-session",
"goose-session",
"goose",
"/Users/aharvard/.goose/artifacts",
);
setActiveMessageId("goose-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "shell",
},
} as never);
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Command failed.",
},
},
],
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
status: "error",
});
expect(message.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
isError: true,
result: "Command failed.",
});
});
});
@@ -9,6 +9,7 @@ import {
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import type {
ToolCallStatus,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
@@ -30,6 +31,7 @@ import {
getLocalSessionId,
subscribeToSessionRegistration,
} from "./acpSessionTracker";
import { getToolCallIdentity } from "./acpToolCallIdentity";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per goose session
@@ -53,6 +55,9 @@ const pendingUsageUpdates = new Map<
{ accumulatedTotal: number; contextLimit: number }
>();
const toolCallStatusFromUpdate = (status: string): ToolCallStatus =>
status === "failed" ? "error" : "completed";
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
if (!pendingUsage) {
@@ -180,6 +185,7 @@ function handleReplay(
case "tool_call": {
const created = getReplayCreated(update);
const identity = getToolCallIdentity(update);
const msg = ensureReplayAssistantMessage(
sessionId,
getReplayMessageId(update),
@@ -189,6 +195,7 @@ function handleReplay(
type: "toolRequest",
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
status: "executing",
startedAt: created ?? Date.now(),
@@ -199,6 +206,7 @@ function handleReplay(
case "tool_call_update": {
const created = getReplayCreated(update);
const replayMessageId = getReplayMessageId(update);
const identity = getToolCallIdentity(update);
const trackedMessageId = getTrackedReplayAssistantMessageId(sessionId);
const replayMsg = replayMessageId
? getBufferedMessage(sessionId, replayMessageId)
@@ -216,15 +224,19 @@ function handleReplay(
if (created !== undefined && !existingMsg && msg === replayMsg) {
msg.created = created;
}
if (update.title) {
if (update.title || Object.keys(identity).length > 0) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
);
if (tc && tc.type === "toolRequest") {
(tc as ToolRequestContent).name = update.title;
Object.assign(tc as ToolRequestContent, {
...(update.title ? { name: update.title } : {}),
...identity,
});
}
}
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
);
@@ -233,7 +245,8 @@ function handleReplay(
if (idx >= 0) {
msg.content[idx] = {
...tc,
status: "completed",
...identity,
status: toolCallStatus,
} as ToolRequestContent;
}
}
@@ -299,11 +312,13 @@ function handleLive(
case "tool_call": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const identity = getToolCallIdentity(update);
const toolRequest: ToolRequestContent = {
type: "toolRequest",
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
status: "executing",
startedAt: Date.now(),
@@ -315,19 +330,25 @@ function handleLive(
case "tool_call_update": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const identity = getToolCallIdentity(update);
if (update.title) {
if (update.title || Object.keys(identity).length > 0) {
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
content: msg.content.map((c) =>
c.type === "toolRequest" && c.id === update.toolCallId
? { ...c, name: update.title ?? "" }
? {
...c,
...(update.title ? { name: update.title } : {}),
...identity,
}
: c,
),
}));
}
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const streamingMessage = store.messagesBySession[sessionId]?.find(
(m) => m.id === messageId,
);
@@ -339,7 +360,11 @@ function handleLive(
...msg,
content: msg.content.map((block) =>
block.type === "toolRequest" && block.id === update.toolCallId
? { ...block, status: "completed" }
? {
...block,
...identity,
status: toolCallStatus,
}
: block,
),
}));
@@ -0,0 +1,36 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk";
export interface ToolCallIdentity {
toolName?: string;
extensionName?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function getToolCallIdentity(update: SessionUpdate): ToolCallIdentity {
if (!isRecord(update._meta)) {
return {};
}
const goose = update._meta.goose;
if (!isRecord(goose)) {
return {};
}
const toolCall = isRecord(goose.mcpApp)
? goose.mcpApp
: isRecord(goose.toolCall)
? goose.toolCall
: null;
if (!toolCall) return {};
return {
...(typeof toolCall.toolName === "string"
? { toolName: toolCall.toolName }
: {}),
...(typeof toolCall.extensionName === "string"
? { extensionName: toolCall.extensionName }
: {}),
};
}
@@ -45,9 +45,7 @@
"empty": {
"folderNotSet": "Folder not set",
"noChanges": "No uncommitted changes",
"noProjectAssigned": "No project assigned",
"noExtensions": "No extensions enabled",
"noMatchingExtensions": "No matching extensions"
"noProjectAssigned": "No project assigned"
},
"errors": {
"gitRead": "Couldn't read git status."
@@ -91,8 +89,6 @@
"artifacts": "Artifacts",
"changes": "Changes",
"changesOnBranch": "on",
"extensions": "Extensions",
"searchExtensions": "Search extensions...",
"workspace": "Workspace"
}
},
@@ -83,16 +83,17 @@
"extensions": {
"title": "Extensions",
"search": "Search extensions...",
"enabled": "Enabled",
"enabledCount": "Enabled ({{count}})",
"available": "Available",
"availableCount": "Available ({{count}})",
"empty": "No extensions configured.",
"noResults": "No extensions match your search.",
"addExtension": "Add extension",
"editExtension": "Edit Extension",
"deleteExtension": "Delete Extension",
"toggle": "Toggle {{name}}",
"deleteConfirmation": {
"title": "Delete \"{{name}}\" permanently?",
"description": "This will permanently remove this extension and all of its settings.",
"confirm": "Delete Extension",
"deleting": "Deleting..."
},
"configure": "Configure {{name}}",
"save": "Save",
"cancel": "Cancel",
@@ -120,8 +121,20 @@
"streamable_http": "HTTP",
"builtin": "Built-in"
},
"filters": {
"all": "All"
},
"categories": {
"appsServices": "Apps & services",
"gooseCapabilities": "Goose capabilities"
},
"sections": {
"extensions": "Extensions",
"gooseCapabilities": "Built-in Goose capabilities"
},
"showGooseCapabilities": "Show {{count}} built-in Goose capabilities",
"hideGooseCapabilities": "Hide built-in Goose capabilities",
"errors": {
"toggleFailed": "Failed to toggle extension. Please try again.",
"saveFailed": "Failed to save extension. Please try again.",
"deleteFailed": "Failed to delete extension. Please try again.",
"nameConflict": "An extension named \"{{name}}\" already exists."
@@ -198,7 +211,6 @@
"doctor": "Doctor",
"general": "General",
"projects": "Projects",
"extensions": "Extensions",
"providers": "Providers",
"voice": "Voice"
},
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agents",
"extensions": "Extensions",
"home": "Home",
"sessionHistory": "Session history",
"skills": "Skills"
@@ -45,9 +45,7 @@
"empty": {
"folderNotSet": "Carpeta no configurada",
"noChanges": "No hay cambios sin confirmar",
"noProjectAssigned": "No hay proyecto asignado",
"noExtensions": "No hay extensiones habilitadas",
"noMatchingExtensions": "No hay extensiones que coincidan"
"noProjectAssigned": "No hay proyecto asignado"
},
"errors": {
"gitRead": "No se pudo leer el estado de git."
@@ -91,8 +89,6 @@
"artifacts": "Artefactos",
"changes": "Cambios",
"changesOnBranch": "en",
"extensions": "Extensiones",
"searchExtensions": "Buscar extensiones...",
"workspace": "Espacio de trabajo"
}
},
@@ -83,16 +83,17 @@
"extensions": {
"title": "Extensiones",
"search": "Buscar extensiones...",
"enabled": "Habilitadas",
"enabledCount": "Habilitadas ({{count}})",
"available": "Disponibles",
"availableCount": "Disponibles ({{count}})",
"empty": "No hay extensiones configuradas.",
"noResults": "Ninguna extensión coincide con tu búsqueda.",
"addExtension": "Agregar extensión",
"editExtension": "Editar extensión",
"deleteExtension": "Eliminar extensión",
"toggle": "Activar/desactivar {{name}}",
"deleteConfirmation": {
"title": "¿Eliminar \"{{name}}\" de forma permanente?",
"description": "Esto eliminará de forma permanente esta extensión y toda su configuración.",
"confirm": "Eliminar extensión",
"deleting": "Eliminando..."
},
"configure": "Configurar {{name}}",
"save": "Guardar",
"cancel": "Cancelar",
@@ -120,8 +121,20 @@
"streamable_http": "HTTP",
"builtin": "Integrada"
},
"filters": {
"all": "Todas"
},
"categories": {
"appsServices": "Apps y servicios",
"gooseCapabilities": "Capacidades de Goose"
},
"sections": {
"extensions": "Extensiones",
"gooseCapabilities": "Capacidades integradas de Goose"
},
"showGooseCapabilities": "Mostrar {{count}} capacidades integradas de Goose",
"hideGooseCapabilities": "Ocultar capacidades integradas de Goose",
"errors": {
"toggleFailed": "Error al cambiar la extensión. Inténtalo de nuevo.",
"saveFailed": "Error al guardar la extensión. Inténtalo de nuevo.",
"deleteFailed": "Error al eliminar la extensión. Inténtalo de nuevo.",
"nameConflict": "Ya existe una extensión llamada \"{{name}}\"."
@@ -198,7 +211,6 @@
"doctor": "Diagnóstico",
"general": "General",
"projects": "Proyectos",
"extensions": "Extensiones",
"providers": "Proveedores",
"voice": "Voz"
},
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agentes",
"extensions": "Extensiones",
"home": "Inicio",
"sessionHistory": "Historial de sesiones",
"skills": "Habilidades"
+2
View File
@@ -78,6 +78,8 @@ export interface ToolRequestContent {
type: "toolRequest";
id: string;
name: string;
toolName?: string;
extensionName?: string;
arguments: Record<string, unknown>;
status: ToolCallStatus;
/** Epoch ms when the tool call started executing (set on event receipt). */
@@ -21,7 +21,7 @@ export function SessionActivityIndicator({
role="status"
aria-label="Chat active"
className={cn(
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-background shadow-sm transition-opacity duration-200 ease-out animate-in fade-in-0",
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center transition-opacity duration-200 ease-out animate-in fade-in-0",
className,
)}
>
@@ -0,0 +1,33 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConfirmDialog } from "./confirm-dialog";
describe("ConfirmDialog", () => {
it("routes rejected confirm actions to onConfirmError", async () => {
const user = userEvent.setup();
const error = new Error("Delete failed");
const onConfirm = vi.fn().mockRejectedValue(error);
const onConfirmError = vi.fn();
render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title="Delete item?"
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={onConfirm}
onConfirmError={onConfirmError}
/>,
);
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledOnce();
await waitFor(() => {
expect(onConfirmError).toHaveBeenCalledWith(error);
});
});
});
@@ -0,0 +1,92 @@
import type * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Button, type ButtonProps } from "@/shared/ui/button";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description: React.ReactNode;
cancelLabel: React.ReactNode;
confirmLabel: React.ReactNode;
loadingLabel?: React.ReactNode;
isLoading?: boolean;
confirmVariant?: ButtonProps["variant"];
contentClassName?: string;
overlayClassName?: string;
positionerClassName?: string;
onConfirm: () => void | Promise<void>;
onConfirmError?: (error: unknown) => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
cancelLabel,
confirmLabel,
loadingLabel,
isLoading = false,
confirmVariant = "destructive",
contentClassName = "max-w-sm",
overlayClassName,
positionerClassName,
onConfirm,
onConfirmError,
}: ConfirmDialogProps) {
const handleConfirm = async () => {
try {
await onConfirm();
} catch (error) {
onConfirmError?.(error);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isLoading) {
onOpenChange(nextOpen);
}
}}
>
<DialogContent
className={contentClassName}
overlayClassName={overlayClassName}
positionerClassName={positionerClassName}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</Button>
<Button
type="button"
variant={confirmVariant}
disabled={isLoading}
onClick={() => void handleConfirm()}
>
{isLoading && loadingLabel ? loadingLabel : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+9 -2
View File
@@ -47,17 +47,24 @@ function DialogOverlay({
function DialogContent({
className,
children,
overlayClassName,
positionerClassName,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
positionerClassName?: string;
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-positioner"
className="pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4"
className={cn(
"pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4",
positionerClassName,
)}
>
<DialogPrimitive.Content
data-slot="dialog-content"