diff --git a/.env.example b/.env.example
index b03d6cab2..d814c862b 100644
--- a/.env.example
+++ b/.env.example
@@ -33,6 +33,9 @@ TENANT_PACKAGE_DIR=../examples/fintech
# session, no same-origin access, and no route into your data. It can load libraries from a CDN, so a
# deployment that must not reach the public internet from a browser tab should set this to false.
# OPENBOT_GENERATIVE_UI=false
+# The signed-in app shows a bar offering CopilotKit's help self-hosting OpenBot, until each person
+# closes it. A fork running OpenBot for its own organization can turn it off for everybody.
+# OPENBOT_SELF_HOST_BANNER=false
# What this deployment calls itself, when more than one shares an Intelligence project. A copy of a
# deployment made for development uses the same project key, and threads are listed per Bot with
# nothing to say which deployment a conversation came from. The name goes into every thread id this
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fbe52911..1062725c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- An existing Windows clone checks text files out with LF only after
`git rm -r --cached . && git reset --hard` on a clean tree.
+### The app offers help self-hosting OpenBot, until you close it
+
+A slim bar at the top of the signed-in app links to CopilotKit's engineers for help self-hosting
+OpenBot. Closing it is saved to your preferences, so it stays closed on every device. A fork running
+OpenBot for its own organization hides it for everybody with `OPENBOT_SELF_HOST_BANNER=false`.
+
### A request to the approvals API that is not JSON answers 400
A body that could not be parsed as JSON, sent to any approvals route that reads one, such as
diff --git a/app/src/components/layout/self-host-banner.tsx b/app/src/components/layout/self-host-banner.tsx
new file mode 100644
index 000000000..e1d933301
--- /dev/null
+++ b/app/src/components/layout/self-host-banner.tsx
@@ -0,0 +1,74 @@
+import { IconX } from "@tabler/icons-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
+import {
+ dismissSelfHostBannerMutationOptions,
+ useUserPreferences,
+} from "@/lib/settings/message-list";
+import { cn } from "@/lib/utils";
+
+/** `ref` tells CopilotKit the visit came from inside a running OpenBot, not from the README. */
+export const SELF_HOST_URL =
+ "https://copilotkit.ai/talk-to-an-engineer?ref=openbot_app";
+
+/**
+ * A slim bar offering help self-hosting OpenBot, until this person closes it.
+ *
+ * Hidden until both answers are in, so it never flashes on and then off: the deployment has to say
+ * the banner is on (a fork running OpenBot for its own people turns it off), and this person's
+ * preferences have to say they have not closed it.
+ */
+export function SelfHostBanner() {
+ const queryClient = useQueryClient();
+ const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions());
+ const { data: preferences, userId } = useUserPreferences();
+ const dismiss = useMutation(
+ dismissSelfHostBannerMutationOptions(queryClient, userId),
+ );
+
+ if (!capabilities?.selfHostBanner) return null;
+ if (!preferences || preferences.selfHostBannerDismissed) return null;
+
+ return (
+
+ );
+}
diff --git a/app/src/lib/deployment/queries.ts b/app/src/lib/deployment/queries.ts
index 66c7095b2..48bda770e 100644
--- a/app/src/lib/deployment/queries.ts
+++ b/app/src/lib/deployment/queries.ts
@@ -18,6 +18,11 @@ export type DeploymentCapabilities = {
* so both halves read this one answer.
*/
generativeUi: boolean;
+ /**
+ * Whether to show the banner offering help self-hosting OpenBot. A fork that runs OpenBot for its
+ * own organization turns it off with OPENBOT_SELF_HOST_BANNER=false.
+ */
+ selfHostBanner: boolean;
transcription?: boolean;
voice?: boolean;
};
@@ -55,12 +60,14 @@ export function deploymentCapabilitiesQueryOptions() {
})
).json()) as {
generativeUi?: boolean;
+ selfHostBanner?: boolean;
transcription?: boolean;
voice?: boolean;
};
return {
generativeUi: body.generativeUi === true,
+ selfHostBanner: body.selfHostBanner === true,
transcription: body.transcription === true,
voice: body.voice === true,
};
diff --git a/app/src/lib/settings/message-list.ts b/app/src/lib/settings/message-list.ts
index 2be806ace..1516bdbb5 100644
--- a/app/src/lib/settings/message-list.ts
+++ b/app/src/lib/settings/message-list.ts
@@ -35,6 +35,45 @@ export function useMessageListEmphasis() {
return useUserPreferences().data?.messageListEmphasis ?? "thread";
}
+/**
+ * Close the self-host banner for this person, on every device they sign in from.
+ *
+ * Patched in onMutate so the bar is gone the moment the button is pressed, and put back if the save
+ * fails, because a banner that vanished and then reappeared on the next load would read as ignoring
+ * the click. The reply seeds the cache with what the server stored.
+ */
+export function dismissSelfHostBannerMutationOptions(
+ queryClient: QueryClient,
+ userId: string | undefined,
+) {
+ const queryKey = userPreferencesQueryOptions(userId).queryKey;
+ return mutationOptions({
+ mutationFn: (): Promise => {
+ if (!userId) throw new Error("Sign in to save your preferences.");
+ return client("/api/settings/preferences", "preferences", {
+ method: "PATCH",
+ body: { selfHostBannerDismissed: true },
+ fallback: "Could not save your preferences",
+ });
+ },
+ onMutate: () => {
+ const previous = queryClient.getQueryData(queryKey);
+ if (previous) {
+ queryClient.setQueryData(queryKey, {
+ ...previous,
+ selfHostBannerDismissed: true,
+ });
+ }
+ return { previous };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previous)
+ queryClient.setQueryData(queryKey, context.previous);
+ },
+ onSuccess: (preferences) => queryClient.setQueryData(queryKey, preferences),
+ });
+}
+
export function saveUserPreferencesMutationOptions(
queryClient: QueryClient,
userId: string | undefined,
diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx
index dcf51a53d..0868efcad 100644
--- a/app/src/routes/_authed/_app.tsx
+++ b/app/src/routes/_authed/_app.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "@/components/app-sidebar/app-sidebar";
+import { SelfHostBanner } from "@/components/layout/self-host-banner";
import { SidebarShell } from "@/components/layout/sidebar-shell";
export const Route = createFileRoute("/_authed/_app")({
@@ -13,6 +14,7 @@ function RouteComponent() {
+
diff --git a/app/tests/channel-pagination.test.tsx b/app/tests/channel-pagination.test.tsx
index b622f6e45..11eb16592 100644
--- a/app/tests/channel-pagination.test.tsx
+++ b/app/tests/channel-pagination.test.tsx
@@ -166,6 +166,7 @@ function renderSidebar() {
clients.push(queryClient);
queryClient.setQueryData(userPreferencesQueryOptions("user").queryKey, {
messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
});
queryClient.setQueryData(authKeys.currentUser(), {
id: "user",
@@ -218,6 +219,7 @@ test("message emphasis updates mounted rows from the account preferences cache",
act(() =>
clients[0]?.setQueryData(userPreferencesQueryOptions("user").queryKey, {
messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
}),
);
await waitFor(() => expect(agent.className).toContain("text-[0.9rem]"));
diff --git a/app/tests/self-host-banner.test.tsx b/app/tests/self-host-banner.test.tsx
new file mode 100644
index 000000000..53a32e340
--- /dev/null
+++ b/app/tests/self-host-banner.test.tsx
@@ -0,0 +1,148 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import {
+ SELF_HOST_URL,
+ SelfHostBanner,
+} from "@/components/layout/self-host-banner";
+import { authKeys } from "@/lib/auth/queries";
+import { deploymentKeys } from "@/lib/deployment/queries";
+import { userPreferencesQueryOptions } from "@/lib/settings/message-list";
+import type { UserPreferences } from "../../shared/user-preferences";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+let response: () => Promise;
+let requests: { path: string; method: string; body: unknown }[];
+beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" }));
+beforeEach(() => {
+ requests = [];
+ response = async () =>
+ Response.json({
+ preferences: {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: true,
+ },
+ });
+ globalThis.fetch = Object.assign(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ requests.push({
+ path: String(input),
+ method: init?.method ?? "GET",
+ body: init?.body,
+ });
+ return response();
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+});
+afterEach(() => {
+ cleanup();
+ for (const client of clients) client.clear();
+ clients.length = 0;
+ globalThis.fetch = originalFetch;
+});
+afterAll(() => GlobalRegistrator.unregister());
+
+const banner = { name: "Self-host OpenBot" } as const;
+
+function setup({
+ enabled = true,
+ dismissed = false,
+}: {
+ enabled?: boolean;
+ dismissed?: boolean;
+} = {}) {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Infinity },
+ mutations: { retry: false },
+ },
+ });
+ clients.push(client);
+ client.setQueryData(authKeys.currentUser(), {
+ id: "alice",
+ email: "alice@example.com",
+ role: "user",
+ });
+ client.setQueryData(deploymentKeys.capabilities(), {
+ generativeUi: true,
+ selfHostBanner: enabled,
+ });
+ const preferences: UserPreferences = {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: dismissed,
+ };
+ client.setQueryData(
+ userPreferencesQueryOptions("alice").queryKey,
+ preferences,
+ );
+ const view = render(
+
+
+ ,
+ );
+ return { client, view };
+}
+
+test("offers self-hosting with a link CopilotKit can tell came from the app", () => {
+ const { view } = setup();
+ expect(
+ view.getByText("Self-host OpenBot for your organization."),
+ ).toBeTruthy();
+ const link = view.getByRole("link", { name: "Talk to an engineer" });
+ expect(link.getAttribute("href")).toBe(SELF_HOST_URL);
+ expect(SELF_HOST_URL).toContain("ref=openbot_app");
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+});
+
+test("stays hidden for somebody who closed it", () => {
+ const { view } = setup({ dismissed: true });
+ expect(view.queryByRole("complementary", banner)).toBeNull();
+});
+
+test("stays hidden when the deployment turned it off", () => {
+ const { view } = setup({ enabled: false });
+ expect(view.queryByRole("complementary", banner)).toBeNull();
+});
+
+test("closing it hides it at once and saves the choice to this person's preferences", async () => {
+ const { client, view } = setup();
+ fireEvent.click(view.getByRole("button", { name: "Dismiss" }));
+ // Hidden from the cache patch, before the save has answered.
+ await waitFor(() =>
+ expect(view.queryByRole("complementary", banner)).toBeNull(),
+ );
+ await waitFor(() => expect(requests.length).toBe(1));
+ expect(requests[0]).toEqual({
+ path: "/api/settings/preferences",
+ method: "PATCH",
+ body: JSON.stringify({ selfHostBannerDismissed: true }),
+ });
+ await waitFor(() =>
+ expect(
+ client.getQueryData(
+ userPreferencesQueryOptions("alice").queryKey,
+ )?.selfHostBannerDismissed,
+ ).toBe(true),
+ );
+});
+
+test("comes back if the save fails, so the click is not silently lost", async () => {
+ response = async () =>
+ Response.json({ error: "Could not save" }, { status: 500 });
+ const { view } = setup();
+ fireEvent.click(view.getByRole("button", { name: "Dismiss" }));
+ await waitFor(() =>
+ expect(view.getByRole("complementary", banner)).toBeTruthy(),
+ );
+});
diff --git a/app/tests/user-preferences.test.tsx b/app/tests/user-preferences.test.tsx
index 9c5697179..257794545 100644
--- a/app/tests/user-preferences.test.tsx
+++ b/app/tests/user-preferences.test.tsx
@@ -96,12 +96,20 @@ test("successful writes update only the current account cache; failures preserve
const { client, view } = setup();
await view.findByText("General Assistant");
const bobKey = userPreferencesQueryOptions("bob").queryKey;
- client.setQueryData(bobKey, { messageListEmphasis: "agent" });
+ client.setQueryData(bobKey, {
+ messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
+ });
const mutation = client
.getMutationCache()
.build(client, saveUserPreferencesMutationOptions(client, "alice"));
response = async () =>
- Response.json({ preferences: { messageListEmphasis: "thread" } });
+ Response.json({
+ preferences: {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
+ },
+ });
await mutation.execute({ messageListEmphasis: "thread" });
await waitFor(() =>
expect(view.getByText("Plan next week").className).toContain(
@@ -115,6 +123,7 @@ test("successful writes update only the current account cache; failures preserve
});
expect(client.getQueryData(bobKey)).toEqual({
messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
});
response = async () =>
Response.json({ error: "Could not save" }, { status: 500 });
@@ -125,5 +134,5 @@ test("successful writes update only the current account cache; failures preserve
client.getQueryData(
userPreferencesQueryOptions("alice").queryKey,
),
- ).toEqual({ messageListEmphasis: "thread" });
+ ).toEqual({ messageListEmphasis: "thread", selfHostBannerDismissed: false });
});
diff --git a/docs/configuration.md b/docs/configuration.md
index 7561713de..3a989058d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -63,6 +63,7 @@ at `agent-langgraph` on a laptop.
| `AUDIT_RETENTION_DAYS` | unset | Whole number of days to keep audit rows; older ones are removed. Unset keeps the trail forever. |
| `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. |
| `OPENBOT_GENERATIVE_UI` | unset (capability on) | Set `false` or `0` to stop Bots from answering with generated interfaces. |
+| `OPENBOT_SELF_HOST_BANNER` | unset (banner on) | Set `false` or `0` to hide the bar offering help self-hosting OpenBot, for everybody. Each person can also close it for themselves. |
| `OPENBOT_ACCESSIBILITY_DISABLED` | unset | `true` or `1` stops naming OpenBot on the analytics the runtime already sends. |
| `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, there is nothing to connect, nothing to grant and no Composio tool for a Bot to call; what remains is one row that goes nowhere, under **More apps** on the admin Plugins page, naming this variable. See [Composio](plugins/composio.md). |
@@ -73,6 +74,13 @@ Set `OPENBOT_GENERATIVE_UI=false` or `0` to disable both. `true`, `1`, an empty
value leave the capability on. The server configures both runtime renderers and reports the same
setting through `/api/capabilities` to the browser.
+**`OPENBOT_SELF_HOST_BANNER`** shows a slim bar at the top of the signed-in app offering
+CopilotKit's help self-hosting OpenBot, linking to `https://copilotkit.ai/talk-to-an-engineer` with
+`ref=openbot_app`. Closing it is saved to that person's preferences, so it stays closed on every
+device they sign in from. Set `false` or `0` to hide it for everybody, which suits a fork running
+OpenBot for its own organization. Any other value, or none, leaves it on. In Helm, set it through
+`config.extraEnv`.
+
The component catalogue has separate per-Bot grants. Its sortable data table (`showTable`),
interactive form (`askForm`), and other compiled or playground-authored components remain governed
by those grants. In Admin → Playground, edit a draft and its sample arguments, preview it, then
diff --git a/server/src/app.ts b/server/src/app.ts
index d43e5d796..ce45c43ba 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -436,6 +436,8 @@ export function createApp(
* both halves, so off means off.
*/
generativeUi: config.generativeUi,
+ // Whether to offer help self-hosting OpenBot. A fork running it for its own people turns it off.
+ selfHostBanner: config.selfHostBanner,
transcription: Boolean(config.transcription),
voice: Boolean(config.voice),
/*
diff --git a/server/src/config.ts b/server/src/config.ts
index 451b1b45e..604c562fe 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -301,6 +301,14 @@ export type DeploymentConfig = {
* that must not reach the public internet from a browser tab needs to weigh.
*/
generativeUi: boolean;
+ /**
+ * Whether the signed-in app shows the banner offering help self-hosting OpenBot.
+ *
+ * On by default, because a fresh clone is somebody evaluating the template. A fork that runs
+ * OpenBot for its own organization turns it off with OPENBOT_SELF_HOST_BANNER=false or
+ * OPENBOT_SELF_HOST_BANNER=0, since its people have nothing to self-host.
+ */
+ selfHostBanner: boolean;
/**
* Where the built app is, when this process serves it.
*
@@ -1168,6 +1176,12 @@ function generativeUiEnabled(environment: Environment): boolean {
return value !== "false" && value !== "0";
}
+/** Same rule as generated interfaces: on unless explicitly "false" or "0". */
+function selfHostBannerEnabled(environment: Environment): boolean {
+ const value = optional(environment, "OPENBOT_SELF_HOST_BANNER");
+ return value !== "false" && value !== "0";
+}
+
/**
* How long the audit trail is kept.
*
@@ -1294,6 +1308,7 @@ export function loadConfig(
singleUserAllowed(environment, configuredAuthProviders(auth).length > 0),
accessibility: accessibilityEnabled(environment),
generativeUi: generativeUiEnabled(environment),
+ selfHostBanner: selfHostBannerEnabled(environment),
...(optional(environment, "APP_DIST_DIR")
? { appDistDir: optional(environment, "APP_DIST_DIR") as string }
: {}),
diff --git a/server/src/user-preferences-routes.ts b/server/src/user-preferences-routes.ts
index 03b491f9f..39792c317 100644
--- a/server/src/user-preferences-routes.ts
+++ b/server/src/user-preferences-routes.ts
@@ -3,9 +3,17 @@ import { z } from "zod";
import type { AppVariables } from "./auth/guards";
import type { UserPreferencesStore } from "./user-preferences";
-const preferencesPatch = z.strictObject({
- messageListEmphasis: z.enum(["agent", "thread"]),
-});
+/*
+ * Each preference is optional so one control can save its own without restating the others, and
+ * the merge in Postgres keeps the rest. Strict, so an unknown key is refused rather than stored, and
+ * at least one key, so an empty body is a mistake rather than a no-op write.
+ */
+const preferencesPatch = z
+ .strictObject({
+ messageListEmphasis: z.enum(["agent", "thread"]).optional(),
+ selfHostBannerDismissed: z.boolean().optional(),
+ })
+ .refine((patch) => Object.keys(patch).length > 0);
export function userPreferencesRoutes(
requireUser: MiddlewareHandler<{ Variables: AppVariables }>,
@@ -34,7 +42,10 @@ export function userPreferencesRoutes(
);
if (!parsed.success) {
return context.json(
- { error: "Choose agent or thread for message list emphasis." },
+ {
+ error:
+ "Choose agent or thread for message list emphasis, or true or false for the self-host banner.",
+ },
400,
);
}
diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts
index 34b2156e7..2fd6872a7 100644
--- a/server/tests/config.test.ts
+++ b/server/tests/config.test.ts
@@ -971,6 +971,32 @@ describe("generated interfaces", () => {
});
});
+describe("the self-host banner", () => {
+ test("is on when nothing is set", () => {
+ expect(loadConfig(baseEnvironment).selfHostBanner).toBe(true);
+ });
+
+ test.each(["false", "0"])(
+ "is off for OPENBOT_SELF_HOST_BANNER=%p",
+ (value) => {
+ expect(
+ loadConfig({ ...baseEnvironment, OPENBOT_SELF_HOST_BANNER: value })
+ .selfHostBanner,
+ ).toBe(false);
+ },
+ );
+
+ test.each(["true", "1", "", "no"])(
+ "stays on for OPENBOT_SELF_HOST_BANNER=%p",
+ (value) => {
+ expect(
+ loadConfig({ ...baseEnvironment, OPENBOT_SELF_HOST_BANNER: value })
+ .selfHostBanner,
+ ).toBe(true);
+ },
+ );
+});
+
/**
* Naming the private addresses an agent may live at.
*
diff --git a/server/tests/health.test.ts b/server/tests/health.test.ts
index 2fb2be880..851d40a5c 100644
--- a/server/tests/health.test.ts
+++ b/server/tests/health.test.ts
@@ -45,6 +45,8 @@ describe("runtime capabilities", () => {
// Default-on. The browser reads this to decide whether to offer the tool that generates an
// interface, so it has to be here and not only in the runtime.
generativeUi: true,
+ // Default-on: a fresh clone is somebody evaluating the template.
+ selfHostBanner: true,
transcription: false,
voice: false,
// Names only. The sign-in screen reads this to know which buttons to draw.
@@ -70,6 +72,7 @@ describe("runtime capabilities", () => {
"mode",
"durableHistory",
"generativeUi",
+ "selfHostBanner",
"transcription",
"voice",
"authProviders",
@@ -100,6 +103,20 @@ describe("runtime capabilities", () => {
expect(response.status).toBe(200);
expect((await response.json()).generativeUi).toBe(false);
});
+
+ // A fork running OpenBot for its own people turns the banner off, and the browser has to hear it.
+ test("reports the self-host banner as off when the deployment opts out", async () => {
+ const disabled = createApp(
+ loadConfig(testEnvironment({ OPENBOT_SELF_HOST_BANNER: "false" })),
+ );
+
+ const response = await disabled.request(
+ "http://openbot.local/api/capabilities",
+ );
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).selfHostBanner).toBe(false);
+ });
});
describe("authentication availability", () => {
diff --git a/server/tests/user-preferences-routes.test.ts b/server/tests/user-preferences-routes.test.ts
index aeee6c686..b8affac22 100644
--- a/server/tests/user-preferences-routes.test.ts
+++ b/server/tests/user-preferences-routes.test.ts
@@ -50,7 +50,10 @@ test("preferences default to thread and persist separately for each authenticate
const { appFor } = fixture();
const alice = appFor("alice");
expect(await (await alice.request(path)).json()).toEqual({
- preferences: { messageListEmphasis: "thread" },
+ preferences: {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
+ },
});
const saved = await alice.request(
path,
@@ -58,16 +61,23 @@ test("preferences default to thread and persist separately for each authenticate
);
expect(saved.status).toBe(200);
expect(await (await appFor("alice").request(path)).json()).toEqual({
- preferences: { messageListEmphasis: "agent" },
+ preferences: {
+ messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
+ },
});
expect(await (await appFor("bob").request(path)).json()).toEqual({
- preferences: { messageListEmphasis: "thread" },
+ preferences: {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
+ },
});
});
test.each([
{},
null,
+ { selfHostBannerDismissed: "yes" },
{ messageListEmphasis: "invalid" },
{ messageListEmphasis: "agent", userId: "bob" },
])("rejects invalid or extra preference fields: %j", async (body) => {
@@ -90,3 +100,26 @@ test("refuses malformed JSON, unauthenticated callers, and unavailable storage",
}
expect(state.size).toBe(0);
});
+
+test("saves the self-host banner dismissal without touching message list emphasis", async () => {
+ const { appFor } = fixture();
+ const alice = appFor("alice");
+ await alice.request(path, patch({ messageListEmphasis: "agent" }));
+ const saved = await alice.request(
+ path,
+ patch({ selfHostBannerDismissed: true }),
+ );
+ expect(saved.status).toBe(200);
+ expect(await (await appFor("alice").request(path)).json()).toEqual({
+ preferences: {
+ messageListEmphasis: "agent",
+ selfHostBannerDismissed: true,
+ },
+ });
+ expect(await (await appFor("bob").request(path)).json()).toEqual({
+ preferences: {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
+ },
+ });
+});
diff --git a/server/tests/user-preferences.integration.test.ts b/server/tests/user-preferences.integration.test.ts
index 02b958a23..94db1b7ad 100644
--- a/server/tests/user-preferences.integration.test.ts
+++ b/server/tests/user-preferences.integration.test.ts
@@ -26,14 +26,22 @@ afterAll(async () => {
test("database defaults and writes survive a new store and remain scoped to the user", async () => {
const alice = await person();
const bob = await person();
- expect(await store.read(alice)).toEqual({ messageListEmphasis: "thread" });
+ expect(await store.read(alice)).toEqual({
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
+ });
expect(await store.patch(alice, { messageListEmphasis: "agent" })).toEqual({
messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
});
expect(await createUserPreferencesStore(database).read(alice)).toEqual({
messageListEmphasis: "agent",
+ selfHostBannerDismissed: false,
+ });
+ expect(await store.read(bob)).toEqual({
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
});
- expect(await store.read(bob)).toEqual({ messageListEmphasis: "thread" });
});
test("patching one preference preserves other JSON fields", async () => {
diff --git a/shared/user-preferences.ts b/shared/user-preferences.ts
index 7f7b27faa..80a9e076d 100644
--- a/shared/user-preferences.ts
+++ b/shared/user-preferences.ts
@@ -2,8 +2,11 @@ export type MessageListEmphasis = "agent" | "thread";
export type UserPreferences = {
messageListEmphasis: MessageListEmphasis;
+ /** Whether this person closed the banner offering help self-hosting OpenBot. */
+ selfHostBannerDismissed: boolean;
};
export const DEFAULT_USER_PREFERENCES: UserPreferences = {
messageListEmphasis: "thread",
+ selfHostBannerDismissed: false,
};