Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions app/src/components/layout/self-host-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside
aria-label="Self-host OpenBot"
className="flex shrink-0 flex-wrap items-center gap-x-2 border-b border-border bg-muted/60 px-4 py-1.5 text-sm"
>
{/*
* Packed from the left rather than pushing Dismiss to the far right: on localhost CopilotKit's
* dev inspector floats over the top-right corner, which is where somebody trying OpenBot first
* sees this, and a Dismiss under it cannot be clicked.
*
* Wraps rather than truncates, so a phone reads the whole sentence. The link and Dismiss stay
* together and drop to the next line as one, which keeps Dismiss on the left there too.
*/}
<p className="text-foreground">
Self-host OpenBot for your organization.
</p>
<span className="flex items-center gap-2">
{/* An anchor, not Button: Base UI gives a rendered anchor role="button", and this is a link. */}
<a
// Foreground, not the link variant's primary, which is a mid grey on the dark bar.
className={cn(
buttonVariants({ size: "sm", variant: "link" }),
"px-0 text-foreground underline",
)}
href={SELF_HOST_URL}
rel="noopener noreferrer"
target="_blank"
>
Talk to an engineer
</a>
<Button
aria-label="Dismiss"
onClick={() => dismiss.mutate()}
size="icon-xs"
variant="ghost"
>
<IconX />
</Button>
</span>
</aside>
);
}
7 changes: 7 additions & 0 deletions app/src/lib/deployment/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
};
Expand Down
39 changes: 39 additions & 0 deletions app/src/lib/settings/message-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserPreferences> => {
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,
Expand Down
2 changes: 2 additions & 0 deletions app/src/routes/_authed/_app.tsx
Original file line number Diff line number Diff line change
@@ -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")({
Expand All @@ -13,6 +14,7 @@ function RouteComponent() {
<SidebarShell className="h-svh overflow-hidden" width="340px">
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
<SelfHostBanner />
<Outlet />
</main>
</SidebarShell>
Expand Down
2 changes: 2 additions & 0 deletions app/tests/channel-pagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function renderSidebar() {
clients.push(queryClient);
queryClient.setQueryData(userPreferencesQueryOptions("user").queryKey, {
messageListEmphasis: "thread",
selfHostBannerDismissed: false,
});
queryClient.setQueryData(authKeys.currentUser(), {
id: "user",
Expand Down Expand Up @@ -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]"));
Expand Down
148 changes: 148 additions & 0 deletions app/tests/self-host-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Response>;
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(
<QueryClientProvider client={client}>
<SelfHostBanner />
</QueryClientProvider>,
);
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<UserPreferences>(
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(),
);
});
15 changes: 12 additions & 3 deletions app/tests/user-preferences.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -115,6 +123,7 @@ test("successful writes update only the current account cache; failures preserve
});
expect(client.getQueryData<UserPreferences>(bobKey)).toEqual({
messageListEmphasis: "agent",
selfHostBannerDismissed: false,
});
response = async () =>
Response.json({ error: "Could not save" }, { status: 500 });
Expand All @@ -125,5 +134,5 @@ test("successful writes update only the current account cache; failures preserve
client.getQueryData<UserPreferences>(
userPreferencesQueryOptions("alice").queryKey,
),
).toEqual({ messageListEmphasis: "thread" });
).toEqual({ messageListEmphasis: "thread", selfHostBannerDismissed: false });
});
Loading
Loading