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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,14 @@ jobs:
# The app's real-browser tests, here because this job has the browser: a running step's words
# under reduced motion, where onboarding's Back lands beside the Mac window's buttons, the name
# step's orb drawn whole under the step dashes, and the New Bot sheet's first four frameworks
# clearing its footer in an 800px window, which only a browser's layout resolves. The root
# playwright-core is the same version as
# clearing its footer in an 800px window, and a conversation's rail of turns clear of the Bot's
# text, which only a browser's layout resolves. The root playwright-core is the same version as
# agent-computer's playwright, so it finds the Chromium installed above. The onboarding frame
# reads the generated app config, which `bun test` alone doesn't write (the test script's
# pretest does).
- run: |
bun run generate:app-config
bun test app/tests/tool-line-reduced-motion.test.tsx app/tests/onboarding-window-buttons.test.tsx app/tests/onboarding-orb-room.test.tsx app/tests/new-bot-sheet-fit.test.tsx
bun test app/tests/tool-line-reduced-motion.test.tsx app/tests/onboarding-window-buttons.test.tsx app/tests/onboarding-orb-room.test.tsx app/tests/new-bot-sheet-fit.test.tsx app/tests/transcript-rail-clearance.test.tsx
env:
OPENBOT_APP_BROWSER: '1'
# Headed is the default for hosted computers, so it is tested as one: the computer starting its
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ deployment:
host-grant-15407-… for shopper-bot".
- An opened "Looked for a tool" step lists the tools the Bot found by name ("Send email on Gmail"),
where it showed their ids and the descriptions the search returned to the Bot.
- The marks for each turn on the left of a conversation no longer sit on the first letters of a
Bot's replies. They show only where the window leaves room for them beside the messages.

### Personal edition

Expand Down
8 changes: 4 additions & 4 deletions app/src/components/channels/transcript-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ export function sameRail(a: RailProps, b: RailProps): boolean {
* Hovering or focusing a mark previews its turn — what the person sent, and the start of the
* reply — and choosing it goes there.
*
* Only where the margin is wide enough to hold it (`@3xl` of the scroller: the transcript's
* `max-w-2xl` column and a gutter either side), and only once there is more than one turn to move
* between.
* Only where the margin is wide enough to hold it clear of the messages (`@4xl` of the scroller,
* 896px: the transcript's 768px `max-w-3xl` column, and 64px either side for the marks and a gap
* after them), and only once there is more than one turn to move between.
*/
export const TranscriptRail = memo(function TranscriptRail({
turns,
Expand All @@ -64,7 +64,7 @@ export const TranscriptRail = memo(function TranscriptRail({
return (
<nav
aria-label="Turns"
className="pointer-events-none absolute inset-y-0 left-2 z-10 hidden items-center @3xl:flex"
className="pointer-events-none absolute inset-y-0 left-2 z-10 hidden items-center @4xl:flex"
>
<ol
className="pointer-events-auto flex max-h-[min(60vh,32rem)] flex-col overflow-y-auto py-2 [scrollbar-width:none]"
Expand Down
52 changes: 52 additions & 0 deletions app/tests/transcript-rail-clearance.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Message } from "@ag-ui/core";
import { CopilotKitProvider } from "@copilotkit/react-core/v2";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { ChatTranscript } from "@/components/channels/chat-transcript";
import { TooltipProvider } from "@/components/ui/tooltip";

/**
* A conversation of several turns, the transcript filling the window, as a page of its own for
* transcript-rail-clearance.test.tsx to bundle and load in Chromium. Enough turns for the rail to
* draw; the Bot's replies are prose, a list and a code block, so their text and their boxes start
* at the column's left edge.
*/
const REPLIES = [
"Here is what changed in the quarterly numbers since last week, and where the gap came from.",
"Three things stand out:\n\n- Signups rose in the second half\n- Churn held flat\n- Expansion revenue doubled",
"The query I ran:\n\n```sql\nselect region, sum(amount) from orders group by region;\n```",
"Shall I draft the summary for the team channel, or send it to you first?",
];
const messages: Message[] = REPLIES.flatMap((reply, index) => [
{
id: `u${index}`,
role: "user" as const,
content: `Question number ${index + 1} about the numbers`,
},
{ id: `a${index}`, role: "assistant" as const, content: reply },
]);

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<QueryClientProvider client={queryClient}>
<CopilotKitProvider runtimeUrl="/api/copilotkit">
<TooltipProvider>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
}}
>
<ChatTranscript messages={messages} restoring={false} />
</div>
</TooltipProvider>
</CopilotKitProvider>
</QueryClientProvider>,
);
}
208 changes: 208 additions & 0 deletions app/tests/transcript-rail-clearance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { Browser, Page } from "playwright-core";
import { compile } from "tailwindcss";

/**
* The rail of turns beside a conversation never sits on the messages. Wherever it draws, the
* column's text starts at least 24px to the right of its marks.
*
* The rail sits in the scroller's left margin, its marks 8px to 40px from the edge. The column is
* 768px and centred, so its text starts 16px inside a margin of half of what is left over. With
* the rail shown from a 768px scroller, the margin was nothing at 768px and under 40px until about
* 850px, and the rail's marks drew over the first letters of the Bot's replies.
*
* Rendered in a real Chromium, because where the text lands beside the rail is the browser's
* layout. The transcript is the real component, bundled from the fixture beside this file; the
* stylesheet is the app's own `styles.css` built by Tailwind for exactly the classes on the page.
* The window is swept through the widths around where the rail starts to draw.
*
* OPENBOT_APP_BROWSER=1 bun test app/tests/transcript-rail-clearance.test.tsx
*
* With OPENBOT_APP_SHOTS=<dir> it also saves screenshots at 1280px, 900px and 800px wide.
*/
const asked = process.env.OPENBOT_APP_BROWSER === "1";
const shots = process.env.OPENBOT_APP_SHOTS;

const APP = join(import.meta.dir, "..");
const ORIGIN = "http://openbot.test";
/** The least room between the rail's marks and the column's text. */
const CLEARANCE = 24;
const LAST_REPLY = "Shall I draft the summary";

/** The fixture bundled for the browser. `@/lib/marks` is Vite's `import.meta.glob`, so no marks. */
async function bundle(): Promise<string> {
const built = await Bun.build({
entrypoints: [
join(import.meta.dir, "transcript-rail-clearance.fixture.tsx"),
],
target: "browser",
define: { "process.env.NODE_ENV": JSON.stringify("production") },
plugins: [
{
name: "no-marks",
setup(build) {
build.onResolve({ filter: /^@\/lib\/marks$/ }, () => ({
path: "marks",
namespace: "no-marks",
}));
build.onLoad({ filter: /.*/, namespace: "no-marks" }, () => ({
contents: "export const markFor = () => null;",
loader: "js",
}));
},
},
],
});
if (!built.success) throw new AggregateError(built.logs, "bundle failed");
const script = built.outputs.find((output) => output.kind === "entry-point");
if (!script) throw new Error("bundle has no entry point");
return script.text();
}

/** Where `@import "<package>[/<file>]"` lands, by the package's `style` export as Vite reads it. */
function packageStylesheet(id: string): string {
const parts = id.split("/");
const name = id.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
const dir = [
join(APP, "node_modules", name),
join(APP, "..", "node_modules", name),
].find(existsSync);
if (!dir) throw new Error(`no package for ${id}`);
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
const subpath = `.${id.slice(name.length)}`;
const entry = pkg.exports?.[subpath];
const file =
typeof entry === "string"
? entry
: (entry?.style ?? entry?.default ?? pkg.main ?? "index.css");
return join(dir, file);
}

/** The app's stylesheet, built by Tailwind for these classes, as the app's build does. */
async function stylesheet(classes: string[]): Promise<string> {
const tailwind = dirname(Bun.resolveSync("tailwindcss/package.json", APP));
const entry = join(APP, "src", "styles.css");
const compiler = await compile(readFileSync(entry, "utf8"), {
base: dirname(entry),
loadStylesheet: async (id, base) => {
const path =
id === "tailwindcss"
? join(tailwind, "index.css")
: id.startsWith(".")
? join(base, id)
: packageStylesheet(id);
return { path, base: dirname(path), content: readFileSync(path, "utf8") };
},
});
return compiler.build(classes);
}

let browser: Browser;
let script: string;

beforeAll(async () => {
if (!asked) return;
script = await bundle();
const { chromium } = await import("playwright-core");
// The CI job installs full Chromium without the headless shell.
browser = await chromium.launch({ channel: "chromium" });
});

afterAll(async () => {
await browser?.close();
});

/** The conversation open in a window this size, styled once it has drawn. */
async function open(width: number, height: number): Promise<Page> {
const page = await browser.newPage({ viewport: { width, height } });
page.on("pageerror", (error) => console.error(error));
await page.route(`${ORIGIN}/**`, (route) => {
const path = new URL(route.request().url()).pathname;
if (path === "/")
return route.fulfill({
contentType: "text/html",
body: '<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/transcript.js"></script></body></html>',
});
if (path === "/transcript.js")
return route.fulfill({ contentType: "text/javascript", body: script });
return route.fulfill({ status: 404 });
});
await page.goto(`${ORIGIN}/`);
await page.getByText(LAST_REPLY).waitFor({ timeout: 8000 });
const classes = await page.evaluate(() => [
...new Set(
[...document.querySelectorAll("[class]")].flatMap((element) => [
...element.classList,
]),
),
]);
await page.addStyleTag({ content: await stylesheet(classes) });
return page;
}

/** Whether the rail draws, its marks' right edge, and where the column's leftmost text starts. */
function measure(page: Page) {
return page.evaluate(async () => {
await new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
);
const rail = document.querySelector('nav[aria-label="Turns"]');
const marks = [...document.querySelectorAll('[data-slot="rail-mark"]')];
const shown = rail !== null && getComputedStyle(rail).display !== "none";
const railRight = Math.max(
...marks.map((mark) => mark.getBoundingClientRect().right),
);
const column = document.querySelector(
'[data-slot="message-scroller-content"]',
);
let textLeft = Number.POSITIVE_INFINITY;
if (column) {
const walker = document.createTreeWalker(column, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
for (const rect of range.getClientRects()) {
if (rect.width > 0) textLeft = Math.min(textLeft, rect.left);
}
}
}
return { shown, railRight, textLeft };
});
}

describe.skipIf(!asked)("the rail of turns beside a conversation", () => {
test("wherever it draws, the Bot's text starts clear of its marks", async () => {
const page = await open(1280, 800);
try {
const crowded: string[] = [];
let drawn = 0;
const widths = [];
for (let width = 740; width <= 1040; width += 8) widths.push(width);
widths.push(1280);
for (const width of widths) {
await page.setViewportSize({ width, height: 800 });
const { shown, railRight, textLeft } = await measure(page);
if (!shown) continue;
drawn += 1;
const gap = Math.round(textLeft - railRight);
if (gap < CLEARANCE) crowded.push(`${width}px wide: ${gap}px`);
}
if (shots) {
mkdirSync(shots, { recursive: true });
for (const width of [1280, 900, 800]) {
await page.setViewportSize({ width, height: 800 });
await measure(page);
await page.screenshot({ path: join(shots, `${width}.png`) });
}
}
// It does draw where there is room, so the clearance above was measured, not skipped.
expect(drawn).toBeGreaterThan(0);
expect(crowded).toEqual([]);
} finally {
await page.close();
}
}, 60_000);
});
Loading