diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 5c6a88574..fa74c36a1 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -69,6 +69,7 @@ import { updateSettings, updateWebSearchSettings, } from "@/lib/api"; +import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; import type { @@ -626,6 +627,9 @@ export function SettingsView({ try { const payload = await runCliAppAction(token, action, name); setCliApps(payload); + if (action !== "test") { + notifyCliAppsChanged(payload); + } setCliAppsMessage(payload.last_action?.message ?? null); setCliAppsFocusName(action === "uninstall" ? null : name); } catch (err) { diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index cd65d3a31..d288e84ad 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -20,6 +20,11 @@ import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; import { fetchCliApps, listSlashCommands } from "@/lib/api"; +import { + CLI_APPS_CHANGED_EVENT, + installedCliAppsFromPayload, + isCliAppsPayload, +} from "@/lib/cli-app-events"; import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types"; import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat"; import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display"; @@ -251,7 +256,7 @@ export function ThreadShell({ const refreshCliApps = useCallback(async () => { try { const payload = await fetchCliApps(token); - setCliApps(payload.apps.filter((app) => app.installed)); + setCliApps(installedCliAppsFromPayload(payload)); } catch { setCliApps([]); } @@ -262,7 +267,7 @@ export function ThreadShell({ const load = async () => { try { const payload = await fetchCliApps(token); - if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed)); + if (!cancelled) setCliApps(installedCliAppsFromPayload(payload)); } catch { if (!cancelled) setCliApps([]); } @@ -275,10 +280,20 @@ export function ThreadShell({ }; window.addEventListener("focus", refreshOnFocus); document.addEventListener("visibilitychange", refreshOnFocus); + const refreshOnCliAppsChanged = (event: Event) => { + const payload = (event as CustomEvent).detail; + if (isCliAppsPayload(payload)) { + setCliApps(installedCliAppsFromPayload(payload)); + return; + } + void refreshCliApps(); + }; + window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged); return () => { cancelled = true; window.removeEventListener("focus", refreshOnFocus); document.removeEventListener("visibilitychange", refreshOnFocus); + window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged); }; }, [refreshCliApps, token]); diff --git a/webui/src/lib/cli-app-events.ts b/webui/src/lib/cli-app-events.ts new file mode 100644 index 000000000..204303047 --- /dev/null +++ b/webui/src/lib/cli-app-events.ts @@ -0,0 +1,22 @@ +import type { CliAppInfo, CliAppsPayload } from "@/lib/types"; + +export const CLI_APPS_CHANGED_EVENT = "nanobot:cli-apps-changed"; + +export function isCliAppsPayload(value: unknown): value is CliAppsPayload { + return ( + !!value && + typeof value === "object" && + Array.isArray((value as { apps?: unknown }).apps) + ); +} + +export function installedCliAppsFromPayload(payload: CliAppsPayload): CliAppInfo[] { + return payload.apps.filter((app) => app.installed); +} + +export function notifyCliAppsChanged(payload: CliAppsPayload): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { + detail: payload, + })); +} diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 9e8a59f2d..4d767e227 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -3,8 +3,9 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ThreadShell } from "@/components/thread/ThreadShell"; +import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events"; import { ClientProvider } from "@/providers/ClientProvider"; -import type { UIMessage } from "@/lib/types"; +import type { CliAppsPayload, UIMessage } from "@/lib/types"; function makeClient() { const errorHandlers = new Set<(err: { kind: string }) => void>(); const chatHandlers = new Map void>>(); @@ -994,4 +995,50 @@ describe("ThreadShell", () => { await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument()); expect(screen.queryByText("from chat a")).not.toBeInTheDocument(); }); + + it("updates @ CLI app suggestions when settings broadcasts an install", async () => { + const client = makeClient(); + render(wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={() => {}} + />, + )); + + const input = await screen.findByLabelText("Message input"); + expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument(); + + const payload: CliAppsPayload = { + apps: [{ + name: "gimp", + display_name: "GIMP", + category: "image", + description: "Image editing", + requires: "", + source: "harness", + entry_point: "cli-anything-gimp", + install_supported: true, + installed: true, + available: true, + status: "installed", + logo_url: null, + brand_color: "#5C5543", + skill_installed: true, + }], + installed_count: 1, + catalog_updated_at: "2026-04-18", + }; + + await act(async () => { + window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload })); + }); + fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); + + expect(screen.getByRole("listbox", { name: "CLI Apps" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument(); + }); });