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); + }); +}