- ) : enabled.length === 0 && available.length === 0 && searchTerm ? (
+ ) : visibleExtensions.length === 0 ? (
- {enabled.length > 0 && (
-
-
- {t("extensions.enabledCount", { count: enabled.length })}
-
-
- {enabled.map((ext) => (
-
- ))}
-
-
- )}
+
+ {activeFilter !== "gooseCapabilities"
+ ? renderSection(
+ t("extensions.sections.extensions"),
+ primaryExtensions,
+ false,
+ )
+ : null}
- {available.length > 0 && (
-
-
- {t("extensions.availableCount", { count: available.length })}
-
-
- {available.map((ext) => (
-
- ))}
-
-
- )}
+ {shouldShowGooseCapabilities
+ ? renderSection(
+ t("extensions.sections.gooseCapabilities"),
+ gooseCapabilities,
+ )
+ : null}
+
+ {showGooseCapabilitiesToggle ? (
+
+ ) : null}
)}
diff --git a/ui/goose2/src/features/extensions/ui/ExtensionsView.tsx b/ui/goose2/src/features/extensions/ui/ExtensionsView.tsx
new file mode 100644
index 00000000..3c0f463d
--- /dev/null
+++ b/ui/goose2/src/features/extensions/ui/ExtensionsView.tsx
@@ -0,0 +1,10 @@
+import { PageShell } from "@/shared/ui/page-shell";
+import { ExtensionsSettings } from "./ExtensionsSettings";
+
+export function ExtensionsView() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/goose2/src/features/extensions/ui/__tests__/ExtensionModal.test.tsx b/ui/goose2/src/features/extensions/ui/__tests__/ExtensionModal.test.tsx
new file mode 100644
index 00000000..7ead3a91
--- /dev/null
+++ b/ui/goose2/src/features/extensions/ui/__tests__/ExtensionModal.test.tsx
@@ -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(
+
,
+ );
+
+ 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(
+
,
+ );
+
+ 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();
+ });
+});
diff --git a/ui/goose2/src/features/extensions/ui/__tests__/ExtensionsSettings.test.tsx b/ui/goose2/src/features/extensions/ui/__tests__/ExtensionsSettings.test.tsx
new file mode 100644
index 00000000..b517c2e3
--- /dev/null
+++ b/ui/goose2/src/features/extensions/ui/__tests__/ExtensionsSettings.test.tsx
@@ -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(
);
+
+ 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(
);
+
+ 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();
+ });
+});
diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx
index 99a079e0..494417ef 100644
--- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx
+++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx
@@ -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" &&
}
{activeSection === "providers" &&
}
{activeSection === "compaction" &&
}
- {activeSection === "extensions" &&
}
{activeSection === "voice" &&
}
{activeSection === "doctor" &&
}
{activeSection === "general" &&
}
diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx
index f4c56ea2..2e600d34 100644
--- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx
+++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx
@@ -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",
diff --git a/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx
index e965d890..19f6dea6 100644
--- a/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx
+++ b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx
@@ -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({
)}
diff --git a/ui/goose2/src/features/skills/ui/SkillIcon.tsx b/ui/goose2/src/features/skills/ui/SkillIcon.tsx
new file mode 100644
index 00000000..e14aa6ee
--- /dev/null
+++ b/ui/goose2/src/features/skills/ui/SkillIcon.tsx
@@ -0,0 +1,3 @@
+import { IconBook } from "@tabler/icons-react";
+
+export const SkillIcon = IconBook;
diff --git a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
index ec3fcaf6..c147bc6d 100644
--- a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
+++ b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
@@ -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",
diff --git a/ui/goose2/src/shared/api/__tests__/acpToolCallStatus.test.ts b/ui/goose2/src/shared/api/__tests__/acpToolCallStatus.test.ts
new file mode 100644
index 00000000..dcbc2ba6
--- /dev/null
+++ b/ui/goose2/src/shared/api/__tests__/acpToolCallStatus.test.ts
@@ -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
(),
+ scrollTargetMessageBySession: {},
+ });
+ });
+
+ it("marks failed replay tool updates as errors", async () => {
+ const replaySessionId = "replay-failed-tool-session";
+ useChatStore.setState({
+ loadingSessionIds: new Set([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.",
+ });
+ });
+});
diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts
index 02857104..d7af4ceb 100644
--- a/ui/goose2/src/shared/api/acpNotificationHandler.ts
+++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts
@@ -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,
),
}));
diff --git a/ui/goose2/src/shared/api/acpToolCallIdentity.ts b/ui/goose2/src/shared/api/acpToolCallIdentity.ts
new file mode 100644
index 00000000..6d512f12
--- /dev/null
+++ b/ui/goose2/src/shared/api/acpToolCallIdentity.ts
@@ -0,0 +1,36 @@
+import type { SessionUpdate } from "@agentclientprotocol/sdk";
+
+export interface ToolCallIdentity {
+ toolName?: string;
+ extensionName?: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ 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 }
+ : {}),
+ };
+}
diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json
index fe543ddb..69d8b251 100644
--- a/ui/goose2/src/shared/i18n/locales/en/chat.json
+++ b/ui/goose2/src/shared/i18n/locales/en/chat.json
@@ -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"
}
},
diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json
index 943c891b..adb94f0d 100644
--- a/ui/goose2/src/shared/i18n/locales/en/settings.json
+++ b/ui/goose2/src/shared/i18n/locales/en/settings.json
@@ -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"
},
diff --git a/ui/goose2/src/shared/i18n/locales/en/sidebar.json b/ui/goose2/src/shared/i18n/locales/en/sidebar.json
index de53baf4..b5816e8c 100644
--- a/ui/goose2/src/shared/i18n/locales/en/sidebar.json
+++ b/ui/goose2/src/shared/i18n/locales/en/sidebar.json
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agents",
+ "extensions": "Extensions",
"home": "Home",
"sessionHistory": "Session history",
"skills": "Skills"
diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json
index 3d91d6f4..a0228014 100644
--- a/ui/goose2/src/shared/i18n/locales/es/chat.json
+++ b/ui/goose2/src/shared/i18n/locales/es/chat.json
@@ -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"
}
},
diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json
index 27e77cb4..44013a03 100644
--- a/ui/goose2/src/shared/i18n/locales/es/settings.json
+++ b/ui/goose2/src/shared/i18n/locales/es/settings.json
@@ -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"
},
diff --git a/ui/goose2/src/shared/i18n/locales/es/sidebar.json b/ui/goose2/src/shared/i18n/locales/es/sidebar.json
index 7fc26e51..eb2d6e79 100644
--- a/ui/goose2/src/shared/i18n/locales/es/sidebar.json
+++ b/ui/goose2/src/shared/i18n/locales/es/sidebar.json
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agentes",
+ "extensions": "Extensiones",
"home": "Inicio",
"sessionHistory": "Historial de sesiones",
"skills": "Habilidades"
diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts
index 6caac990..26f9b4c6 100644
--- a/ui/goose2/src/shared/types/messages.ts
+++ b/ui/goose2/src/shared/types/messages.ts
@@ -78,6 +78,8 @@ export interface ToolRequestContent {
type: "toolRequest";
id: string;
name: string;
+ toolName?: string;
+ extensionName?: string;
arguments: Record;
status: ToolCallStatus;
/** Epoch ms when the tool call started executing (set on event receipt). */
diff --git a/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx b/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx
index 854c0b90..f2723077 100644
--- a/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx
+++ b/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx
@@ -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,
)}
>
diff --git a/ui/goose2/src/shared/ui/confirm-dialog.test.tsx b/ui/goose2/src/shared/ui/confirm-dialog.test.tsx
new file mode 100644
index 00000000..d4d5a0bb
--- /dev/null
+++ b/ui/goose2/src/shared/ui/confirm-dialog.test.tsx
@@ -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(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(onConfirm).toHaveBeenCalledOnce();
+ await waitFor(() => {
+ expect(onConfirmError).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/ui/goose2/src/shared/ui/confirm-dialog.tsx b/ui/goose2/src/shared/ui/confirm-dialog.tsx
new file mode 100644
index 00000000..d347bb9a
--- /dev/null
+++ b/ui/goose2/src/shared/ui/confirm-dialog.tsx
@@ -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;
+ 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 (
+
+ );
+}
diff --git a/ui/goose2/src/shared/ui/dialog.tsx b/ui/goose2/src/shared/ui/dialog.tsx
index 7747a95a..d965c5ea 100644
--- a/ui/goose2/src/shared/ui/dialog.tsx
+++ b/ui/goose2/src/shared/ui/dialog.tsx
@@ -47,17 +47,24 @@ function DialogOverlay({
function DialogContent({
className,
children,
+ overlayClassName,
+ positionerClassName,
showCloseButton = true,
...props
}: React.ComponentProps & {
+ overlayClassName?: string;
+ positionerClassName?: string;
showCloseButton?: boolean;
}) {
return (
-
+