From bddf209273fa585afcccf833f7efd07294081fe0 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 13:51:39 -0700 Subject: [PATCH 1/2] Offer help self-hosting OpenBot in a dismissable banner A slim bar at the top of the signed-in app links to CopilotKit's engineers, with ref=openbot_app so an in-app visit can be told apart from the README. Closing it is saved to the person's preferences, so it stays closed on every device. OPENBOT_SELF_HOST_BANNER=false hides it for a fork that runs OpenBot for its own organization. The controls sit together from the left, because on localhost CopilotKit's dev inspector floats over the top-right corner and covered a Dismiss pinned there. --- .env.example | 3 + CHANGELOG.md | 6 + .../components/layout/self-host-banner.tsx | 69 ++++++++ app/src/lib/deployment/queries.ts | 7 + app/src/lib/settings/message-list.ts | 39 +++++ app/src/routes/_authed/_app.tsx | 2 + app/tests/channel-pagination.test.tsx | 2 + app/tests/self-host-banner.test.tsx | 148 ++++++++++++++++++ app/tests/user-preferences.test.tsx | 15 +- docs/configuration.md | 8 + server/src/app.ts | 2 + server/src/config.ts | 15 ++ server/src/user-preferences-routes.ts | 19 ++- server/tests/config.test.ts | 26 +++ server/tests/health.test.ts | 17 ++ server/tests/user-preferences-routes.test.ts | 39 ++++- .../user-preferences.integration.test.ts | 12 +- shared/user-preferences.ts | 3 + 18 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 app/src/components/layout/self-host-banner.tsx create mode 100644 app/tests/self-host-banner.test.tsx 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 888608113..f6e439d0c 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`. + ### The Helm chart configures Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry These settings had no chart values and could only be passed through `config.extraEnv`. They now have 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..6e5bed129 --- /dev/null +++ b/app/src/components/layout/self-host-banner.tsx @@ -0,0 +1,69 @@ +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, }; From b9f5cb2722f5588d28b0e9af21c83b43702fb59b Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 14:02:49 -0700 Subject: [PATCH 2/2] Wrap the self-host banner instead of truncating it on a phone At 390px the headline cut off at "for your or...", losing the message. The sentence now wraps, and the link and Dismiss drop to the next line together, left-aligned with it, so the whole sentence and both controls stay readable and Dismiss stays clear of the dev inspector. --- .../components/layout/self-host-banner.tsx | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/app/src/components/layout/self-host-banner.tsx b/app/src/components/layout/self-host-banner.tsx index 6e5bed129..e1d933301 100644 --- a/app/src/components/layout/self-host-banner.tsx +++ b/app/src/components/layout/self-host-banner.tsx @@ -33,37 +33,42 @@ export function SelfHostBanner() { return ( ); }