fix(cli): refresh installed apps after settings changes

This commit is contained in:
Xubin Ren 2026-05-23 00:59:54 +08:00
parent 7a6cc657db
commit 9efdce276f
4 changed files with 91 additions and 3 deletions

View File

@ -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) {

View File

@ -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<unknown>).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]);

View File

@ -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<CliAppsPayload>(CLI_APPS_CHANGED_EVENT, {
detail: payload,
}));
}

View File

@ -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<string, Set<(ev: import("@/lib/types").InboundEvent) => 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,
<ThreadShell
session={session("chat-cli-apps")}
title="Chat chat-cli-apps"
onToggleSidebar={() => {}}
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();
});
});