From 477be1c20d0aedbd276b6bf394b1c0c7168b6e36 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 | 84 +++++++++++++++++++++++++++++++ 4 files changed, 190 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..674d016804 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'; @@ -90,6 +91,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null; headings={headings} lang={lang} /> +
diff --git a/tests/open-in-llm.spec.ts b/tests/open-in-llm.spec.ts new file mode 100644 index 0000000000..56c7600958 --- /dev/null +++ b/tests/open-in-llm.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; + +// Runs against `astro preview` (built `dist/`), because the `.md` companion the +// button points at is written by the build-time integration. + +const STABLE_PLAIN_MD_PATH = '/docs/argo-cd'; +const STABLE_MDX_PATH = '/docs/kubernetes'; + +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 expect(menu).toBeVisible(); + + 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); + }); +}