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
8 changes: 6 additions & 2 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ name: Desktop
# The shell is the one thing here that cannot be proved by running it on Linux: it is a macOS app, a
# Windows app and a Linux app built from one tree, and the ways they differ are exactly the ways
# this fails. So it tests and builds on all three, on every change to it.
# `shared/harnesses.json` and `shared/marks/**` are watched here even though they are not under
# desktop/: the catalogue the setup window draws moved there so the web app could read one copy, and
# these jobs are the only ones that prove it still deserializes into `Harness` — `catalogue()` parses
# it at runtime with an `expect`, so a bad row panics the window rather than failing a type check.
on:
pull_request:
paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"]
paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"]
push:
branches: [main]
paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"]
paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"]
workflow_call:
secrets:
OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET:
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/agents/agent-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function AgentProfile({ agentId }: { agentId: string }) {

<section className="grid gap-2">
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Role
What it does
</h2>
<p className="text-sm whitespace-pre-wrap text-pretty">
{profile.roleDescription}
Expand Down
468 changes: 301 additions & 167 deletions app/src/components/agents/create-agent-dialog.tsx

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions app/src/components/agents/harness-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { markFor } from "@/lib/marks";

/**
* A framework's own mark, beside its name.
*
* Draws nothing when the brand has no mark. Three of the frameworks have none in any maintained
* set, and `shared/marks/README.md` is explicit about why nothing is drawn to fill the gap: a
* monogram we drew would read as the vendor's own, which is the thing a trade mark holder actually
* objects to. The caller draws the name either way, so a row without a mark is not a lesser one.
*
* Decorative, always: `alt=""` and `aria-hidden`, because the name is already there in text and a
* screen reader announcing "LangGraph LangGraph" is worse than one announcing it once.
*/
export function HarnessMark({ id, name }: { id: string | null; name: string }) {
const uri = markFor(id);
/*
* An empty box of the same size, not nothing. Four of the twelve rows have no mark, and a
* collapsed box started their names a tile's width to the left of the rest — a ragged list rather
* than four brands without artwork. Reserving the column draws no mark and invents none, which is
* what the README forbids; it only keeps the names in line.
*/
if (!uri) return <span aria-hidden="true" className="size-5 shrink-0" />;
return (
<img
alt=""
aria-hidden="true"
className="size-5 shrink-0"
data-mark={name}
src={uri}
/>
);
}
4 changes: 3 additions & 1 deletion app/src/components/bot-profile/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ const TEXT_FIELDS: {
{ field: "title", label: "Title", icon: IconTag },
{
field: "roleDescription",
label: "Role",
// Not "Role": beside "Title" it read as a second job title, when it is the standing
// instruction the Bot carries into every channel. The create dialog says the same.
label: "What it does",
icon: IconTextCaption,
multiline: true,
},
Expand Down
4 changes: 2 additions & 2 deletions app/src/lib/agents/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const agentFormSchema = z.object({
roleDescription: z
.string()
.trim()
.min(1, "Role description is required.")
.max(1000, "Role description must be 1000 characters or fewer."),
.min(1, "A description of what it does is required.")
.max(1000, "What it does must be 1000 characters or fewer."),
visibility: z.enum(["public", "private"]),
/**
* The AG-UI endpoint this coworker runs on. Empty means the Bot in the box.
Expand Down
28 changes: 28 additions & 0 deletions app/src/lib/marks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* The vendored brand marks, inlined at build time as data URIs.
*
* The same files the desktop setup window draws, read from `shared/marks/` so there is one copy of
* each vendor's artwork rather than two to keep in step. See `shared/marks/README.md` before
* adding, renaming or drawing anything here: the rules in it are the reason a brand with no mark
* shows its name alone instead of a monogram we invented.
*
* As URIs rather than SVG source on purpose: an `<img>` cannot execute anything, so drawing these
* needs no `dangerouslySetInnerHTML` and what is in the file never becomes a security question.
*/
const files = import.meta.glob("../../../shared/marks/*.svg", {
eager: true,
query: "?inline",
import: "default",
}) as Record<string, string>;

const byId = new Map<string, string>(
Object.entries(files).map(([path, uri]) => [
path.replace(/^.*\/marks\//, "").replace(".svg", ""),
uri,
]),
);

/** The mark for a brand id, or null where no maintained set has one. */
export function markFor(id: string | null | undefined): string | null {
return (id && byId.get(id)) ?? null;
}
3 changes: 2 additions & 1 deletion app/src/routes/_authed/_app/bots.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ function BotsPage() {
render={(props) => (
<Link {...props} search={{ new: true }} to="/bots" />
)}
size="lg"
size="sm"
variant="ghost"
>
<IconPlus />
New Bot
Expand Down
7 changes: 4 additions & 3 deletions app/tests/composing-enter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,18 @@ test("the new-coworker wizard does not move on from the Enter that confirms a co
const name = await view.findByLabelText("Name");
await type(name, "経費");
await type(view.getByLabelText("Title"), "Finance Operations");
await type(view.getByLabelText("Role"), "Review receipts.");
await type(view.getByLabelText("What it does"), "Review receipts.");

await confirmComposedCharacter(name);
await settle();
expect(view.getByText("Step 1 of 3")).toBeTruthy();
// The wizard draws no counter, so the step's own heading is what says where we are.
expect(view.getByText("Who is this coworker?")).toBeTruthy();

// An ordinary Enter still means Continue.
await act(async () => {
fireEvent.keyDown(name, { key: "Enter", keyCode: 13 });
});
expect(await view.findByText("Step 2 of 3")).toBeTruthy();
expect(await view.findByText("Where does it run?")).toBeTruthy();
});

test("a boundary rule is not saved by the Enter that confirms a composed character", async () => {
Expand Down
181 changes: 181 additions & 0 deletions app/tests/create-agent-endpoint.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
expect,
mock,
test,
} from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { settleReactWork } from "./settle-react-work";

/*
* `import.meta.glob` is Vite's, and `bun test` has no Vite. Stubbed the way the desktop stubs its
* own marks module, keeping the shape that matters here: a brand with a vendored mark resolves to
* something drawable, and one without resolves to null so its row shows the name alone.
*/
mock.module("@/lib/marks", () => ({
markFor: (id: string | null | undefined) =>
id ? `data:image/svg+xml,${id}` : null,
}));

/*
* Imported after the stub above, not beside the other imports: a static import is hoisted
* and would evaluate the real marks module — and its Vite-only glob — before `mock.module`
* ever ran.
*/
const { CreateAgentDialog } = await import(
"@/components/agents/create-agent-dialog"
);

/**
* The AG-UI endpoint, on a step of its own.
*
* It is generic: every framework leads here and none of them changes a word of it. The step exists
* only for a Bot somebody else hosts, so the Bot that runs on this deployment never sees it.
*/

beforeAll(() => GlobalRegistrator.register());
afterEach(cleanup);
afterAll(async () => {
await settleReactWork();
GlobalRegistrator.unregister();
});

const originalFetch = global.fetch;
/** Every write the dialog sent, so "the endpoint was discarded" is an assertion. */
let writes: { request: string; body: Record<string, unknown> }[] = [];

beforeEach(() => {
writes = [];
global.fetch = Object.assign(
async (
path: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => {
const method = init?.method ?? "GET";
if (path === "/api/agents/capabilities") {
return Response.json({ capabilities: { builtInAvailable: true } });
}
if (method === "POST" && String(path).startsWith("/api/agents")) {
writes.push({
request: `${method} ${path}`,
body: typeof init?.body === "string" ? JSON.parse(init.body) : {},
});
return Response.json({ agent: { id: "expense-manager" } });
}
return new Response(null, { status: 404 });
},
{ preconnect: originalFetch.preconnect },
);
});

afterEach(() => {
global.fetch = originalFetch;
});

function draw(screen: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: createRootRoute({ component: () => screen }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}

async function atKindStep() {
const view = draw(
<CreateAgentDialog onClose={() => {}} onCreated={() => {}} open />,
);
const name = await view.findByLabelText("Name");
const user = userEvent.setup({ document: name.ownerDocument });
await user.type(name, "Expense Manager");
await user.type(view.getByLabelText("Title"), "Finance Operations");
await user.type(view.getByLabelText("What it does"), "Review receipts.");
await user.click(view.getByText("Continue"));
await view.findByText("Where does it run?");
return { view, user };
}

test("a framework leads to one generic endpoint step", async () => {
const { view, user } = await atKindStep();
await user.click(view.getByRole("button", { name: /CrewAI/ }));
const field = await view.findByLabelText("Agent endpoint");
expect(field).toBeTruthy();
/*
* Generic: the framework that led here is not named on it. Scoped to this step's own pane
* rather than the popup, because the pane being slid out is still mounted mid-animation and
* carries the name of the button that was just pressed.
*/
const pane = view
.getByText("Where can it be reached?")
.closest('[data-slot="questionnaire-item"]');
expect(pane?.textContent).not.toContain("CrewAI");
});

test("the Bot that runs here skips the endpoint step entirely", async () => {
const { view, user } = await atKindStep();
await user.click(view.getByRole("button", { name: /Run it here/ }));
expect(await view.findByText("Who can see it?")).toBeTruthy();
expect(view.queryByLabelText("Agent endpoint")).toBeNull();
});

test("going back keeps the framework that was chosen", async () => {
const { view, user } = await atKindStep();
await user.click(view.getByRole("button", { name: /CrewAI/ }));
await view.findByLabelText("Agent endpoint");
await user.click(view.getByText("Back"));
const chosen = await view.findByRole("button", { name: /CrewAI/ });
expect(chosen.getAttribute("aria-pressed")).toBe("true");
});

/*
* Review Focus: an endpoint typed on the way past must not ride along into a Bot that runs here.
* The server stores whatever it is sent, and a built-in Bot carrying somebody's stale URL would be
* dialled at it.
*/
test("choosing Run it here discards an endpoint typed earlier", async () => {
const { view, user } = await atKindStep();
await user.click(view.getByRole("button", { name: /CrewAI/ }));
await user.type(
await view.findByLabelText("Agent endpoint"),
"https://example.com/ag-ui",
);
await user.click(view.getByText("Back"));
await user.click(view.getByRole("button", { name: /Run it here/ }));
await view.findByText("Who can see it?");
await user.click(view.getByText("Create coworker"));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(writes).toHaveLength(1);
expect(writes[0]?.body.endpoint).toBe("");
});

test("Enter in the endpoint field advances the step", async () => {
const { view, user } = await atKindStep();
await user.click(view.getByRole("button", { name: /CrewAI/ }));
const field = await view.findByLabelText("Agent endpoint");
await user.type(field, "https://example.com/ag-ui");
await act(async () => {
fireEvent.keyDown(field, { key: "Enter", keyCode: 13 });
});
expect(await view.findByText("Who can see it?")).toBeTruthy();
});
Loading
Loading