diff --git a/src/components/OpenInLlm.astro b/src/components/OpenInLlm.astro
new file mode 100644
index 0000000000..f2d791ae5c
--- /dev/null
+++ b/src/components/OpenInLlm.astro
@@ -0,0 +1,90 @@
+---
+import { SITE } from '@config';
+import { Lang, Translations } from '@util/Languages';
+import { getPageMarkdownPath } from '@util/mdxContent';
+import SplitButton from './SplitButton.astro';
+
+type Props = {
+ lang: string;
+};
+const { lang } = Astro.props satisfies Props;
+
+const _ = Lang(lang);
+
+const pageMdUrl = getPageMarkdownPath(Astro.url.pathname);
+
+// The assistant fetches the page itself, so the prompt carries an absolute URL
+// rather than the site-relative one the rest of the site links with.
+const prompt = pageMdUrl
+ ? _(Translations.octopus_open_in_llm.prompt).replace(
+ '{url}',
+ new URL(pageMdUrl, SITE.url).href
+ )
+ : '';
+
+const assistants = [
+ {
+ label: _(Translations.octopus_open_in_llm.open_in_claude),
+ href: 'https://claude.ai/new?q=' + encodeURIComponent(prompt),
+ icon: 'octo-llm__icon octo-llm__icon--claude',
+ iconEnd: 'octo-llm__icon octo-llm__icon--external',
+ },
+ {
+ label: _(Translations.octopus_open_in_llm.open_in_chatgpt),
+ href: 'https://chatgpt.com/?hints=search&q=' + encodeURIComponent(prompt),
+ icon: 'octo-llm__icon octo-llm__icon--chatgpt',
+ iconEnd: 'octo-llm__icon octo-llm__icon--external',
+ },
+];
+
+const [primary] = assistants;
+---
+
+{
+ pageMdUrl && (
+
+ )
+}
+
+
diff --git a/src/data/language.json b/src/data/language.json
index 9ef13f16ec..a9c52ee6e2 100644
--- a/src/data/language.json
+++ b/src/data/language.json
@@ -75,6 +75,20 @@
"en": "Copy failed"
}
},
+ "octopus_open_in_llm": {
+ "open_in_claude": {
+ "en": "Open in Claude"
+ },
+ "open_in_chatgpt": {
+ "en": "Open in ChatGPT"
+ },
+ "more_assistants": {
+ "en": "Open in another AI assistant"
+ },
+ "prompt": {
+ "en": "Read {url} so I can ask questions about it."
+ }
+ },
"post": {
"written_by": {
"en": ""
diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro
index 7e99393cbb..604b8fea51 100644
--- a/src/layouts/Default.astro
+++ b/src/layouts/Default.astro
@@ -23,6 +23,7 @@ import ArticleJourney from '../components/ArticleJourney.astro';
import EditOnGithub from '../components/EditOnGithub.astro';
import CopyAsMarkdown from '../components/CopyAsMarkdown.astro';
import MarkdownLinks from '../components/MarkdownLinks.astro';
+import OpenInLlm from '../components/OpenInLlm.astro';
import Plausible from 'src/components/Plausible.astro';
import Footer from 'src/components/Footer.astro';
@@ -85,6 +86,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
+
+ Promise.all(el.getAnimations().map((animation) => animation.finished))
+ );
+}
+
+test('every assistant is handed a working .md URL', async ({
+ page,
+ request,
+}) => {
+ await page.goto(STABLE_PLAIN_MD_PATH);
+
+ const links = await page
+ .locator('.octo-llm a[href]')
+ .evaluateAll((all) => all.map((l) => (l as HTMLAnchorElement).href));
+
+ expect(
+ links.length,
+ 'expected the primary button plus two menu options'
+ ).toBe(3);
+
+ // Each assistant carries the page URL inside its own prompt parameter, so the
+ // parameter name varies but the URL it wraps must not.
+ const mdUrls = new Set(
+ links.map((href) => {
+ const params = new URL(href).searchParams;
+ const prompt = params.get('q') ?? params.get('prompt') ?? '';
+ return prompt.match(/https?:\/\/\S+\.md/)?.[0] ?? '';
+ })
+ );
+ expect(mdUrls.size, 'every assistant should be pointed at one .md URL').toBe(
+ 1
+ );
+
+ const mdUrl = [...mdUrls][0];
+ expect(mdUrl).toContain(STABLE_PLAIN_MD_PATH + '.md');
+
+ // The prompt URL is absolute to production; test it against the preview host.
+ const target = await request.get(mdUrl.replace(/^https?:\/\/[^/]+/, ''));
+ expect(target.status()).toBe(200);
+});
+
+test('the button is absent on a page with no .md companion', async ({
+ page,
+}) => {
+ await page.goto(STABLE_MDX_PATH);
+ const count = await page.locator('.octo-llm').count();
+ expect(count, 'expected no Open in Claude button on ineligible page').toBe(0);
+});
+
+// Where the menu is actually crowded: 700 puts the control at the end of a
+// single row, 430 wraps it onto its own line at the start. The menu is wider
+// than the control, so those two need it to open towards opposite sides.
+for (const width of [700, 430]) {
+ test(`the menu opens on screen at ${width}px wide`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto(STABLE_PLAIN_MD_PATH);
+ await page.locator('.octo-llm [data-split-trigger]').click();
+
+ const menu = page.locator('.octo-llm .split-btn__options');
+ await opened(menu);
+
+ const box = (await menu.boundingBox())!;
+ const viewport = await page.evaluate(() => ({
+ client: document.documentElement.clientWidth,
+ scroll: document.documentElement.scrollWidth,
+ }));
+
+ expect(
+ Math.round(box.x),
+ 'menu should not run off the start edge'
+ ).toBeGreaterThanOrEqual(0);
+ expect(
+ Math.round(box.x + box.width),
+ 'menu should not run off the end edge'
+ ).toBeLessThanOrEqual(viewport.client);
+ expect(
+ viewport.scroll,
+ 'an open menu should not force a horizontal scrollbar'
+ ).toBeLessThanOrEqual(viewport.client);
+ });
+}