From 1068bdb468b35bbf18123deb5b3a6e08a7c16a14 Mon Sep 17 00:00:00 2001 From: William Laugesen Date: Wed, 12 Aug 2026 11:12:25 +1200 Subject: [PATCH] Add an Open in Claude split button to the page actions The primary half opens Claude with a prompt pointing at the page's .md URL. The caret opens a menu with Open in Claude and Open in ChatGPT. It is the SplitButton component, so the halves, divider, menu and positioning come from there. Gemini is absent because gemini.google.com ignores prompt parameters, so the entry could only prefill by pointing at AI Studio, which is not where "Open in Gemini" says it is going. The prompt carries an absolute URL, because the assistant fetches the page itself rather than following a link from this site. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/OpenInLlm.astro | 90 ++++++++++++++++++++++++++++++++ src/data/language.json | 14 +++++ src/layouts/Default.astro | 2 + tests/open-in-llm.spec.ts | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 src/components/OpenInLlm.astro create mode 100644 tests/open-in-llm.spec.ts 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); + }); +}