diff --git a/src/assets/icons/chatgpt.svg b/src/assets/icons/chatgpt.svg
new file mode 100644
index 0000000000..cdd5b878a4
--- /dev/null
+++ b/src/assets/icons/chatgpt.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/claude.svg b/src/assets/icons/claude.svg
new file mode 100644
index 0000000000..316f06e41a
--- /dev/null
+++ b/src/assets/icons/claude.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/external-link.svg b/src/assets/icons/external-link.svg
new file mode 100644
index 0000000000..259c6c254a
--- /dev/null
+++ b/src/assets/icons/external-link.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/components/CopyMarkdown.astro b/src/components/CopyMarkdown.astro
deleted file mode 100644
index 30ba16072d..0000000000
--- a/src/components/CopyMarkdown.astro
+++ /dev/null
@@ -1,87 +0,0 @@
----
-import { SITE } from '@config';
-import { Lang, Translations } from '@util/Languages';
-import { getEligibleSlugs } from '@util/mdxContent';
-
-type Props = {
- lang: string;
-};
-const { lang } = Astro.props satisfies Props;
-
-const _ = Lang(lang);
-
-const docsPath = Astro.url.pathname.replace(/\/$/, '');
-const subfolderPrefix = SITE.subfolder.replace(/\/$/, '') + '/';
-const docsSlug = docsPath.startsWith(subfolderPrefix)
- ? docsPath.slice(subfolderPrefix.length)
- : null;
-const eligibleSlugs = getEligibleSlugs();
-const pageMdUrl =
- docsSlug && eligibleSlugs.has(docsSlug) ? docsPath + '.md' : null;
-const allDocsUrl = SITE.subfolder.replace(/\/$/, '') + '/llms-full.txt';
----
-
-{
- pageMdUrl && (
-
@@ -100,7 +101,6 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
/>
-
{
- labelEl.textContent = restoreLabel;
- }, 2000);
- }
- if (this.liveRegion) {
- // Force a content change so AT re-announces a repeated message.
- this.liveRegion.textContent = '';
- setTimeout(() => {
- this.liveRegion.textContent = message;
- }, 50);
- }
- }
-
- // navigator.clipboard requires a secure context; falls back to
- // execCommand for HTTP and older browsers.
- async writeToClipboard(text) {
- if (
- typeof navigator !== 'undefined' &&
- navigator.clipboard &&
- typeof navigator.clipboard.writeText === 'function' &&
- (typeof window === 'undefined' || window.isSecureContext !== false)
- ) {
- try {
- await navigator.clipboard.writeText(text);
- return true;
- } catch (err) {
- console.warn('[copy-md] navigator.clipboard failed, falling back', err);
- }
- }
-
- return this.execCommandCopyFallback(text);
- }
-
- execCommandCopyFallback(text) {
- if (typeof document === 'undefined') return false;
- const ta = document.createElement('textarea');
- ta.value = text;
- ta.setAttribute('readonly', '');
- ta.style.position = 'fixed';
- ta.style.top = '0';
- ta.style.left = '0';
- ta.style.opacity = '0';
- ta.style.pointerEvents = 'none';
- document.body.appendChild(ta);
- ta.select();
- let ok = false;
- try {
- ok = document.execCommand('copy');
- } catch (err) {
- console.warn('[copy-md] execCommand fallback threw', err);
- ok = false;
- }
- document.body.removeChild(ta);
- return ok;
- }
-
- async handleCopy(btn) {
- const url = btn.dataset.copyMdUrl;
- const success = btn.dataset.copyMdSuccess ?? '';
- const errorMsg = btn.dataset.copyMdError ?? 'Copy failed';
- if (!url) return;
- try {
- const res = await fetch(url);
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const text = await res.text();
- const ok = await this.writeToClipboard(text);
- if (!ok) throw new Error('clipboard-write-failed');
- this.announce(btn, success);
- } catch (err) {
- console.error('[copy-md] failed:', err);
- this.announce(btn, errorMsg);
- }
- }
-
- handleKeyboardNavigation(e) {
- if (!this.menu.open) return;
-
- if (e.key === 'Escape') {
- e.preventDefault();
- this.menu.open = false;
- this.trigger.focus();
- }
- }
-
- handleOutsideClick(e) {
- if (!this.menu.open) return;
- if (e.target instanceof Node && this.menu.contains(e.target)) return;
- this.menu.open = false;
- }
-
- addCopyHandlers() {
- for (const btn of qsa('[data-copy-md-action="copy"]', this.menu)) {
- btn.addEventListener('click', () => this.handleCopy(btn));
- }
- }
-
- addListeners() {
- this.menu.addEventListener('keydown', (e) =>
- this.handleKeyboardNavigation(e)
- );
-
- document.addEventListener('click', (e) => this.handleOutsideClick(e));
- }
-}
-
-const copyMarkdownMenus = Array.from(qsa('[data-copy-md-menu]')).map(
- (menu) => new CopyMarkdown(menu)
-);
-
-export { copyMarkdownMenus };
diff --git a/src/styles/main.css b/src/styles/main.css
index ab43dcb6d3..5035d66f63 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1799,166 +1799,8 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
mask: url('../assets/icons/github.svg') center / contain no-repeat;
}
-/* "Use Octopus docs with AI" dropdown */
-.octo-copy-md {
- margin-block-start: var(--block-gap);
-}
-
-.octo-copy-md__menu {
- display: inline-block;
- position: relative;
-}
-
-.octo-copy-md__trigger {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0.875rem;
- border: 1px solid var(--border-color-menu-open);
- border-radius: 999px;
- background-color: transparent;
- color: var(--color-menu-link);
- font: inherit;
- cursor: pointer;
- list-style: none;
- text-decoration: none;
- user-select: none;
- transition:
- border-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
- background-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
- color 200ms cubic-bezier(0.4, 0, 0.2, 1),
- box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.octo-copy-md__trigger > * {
- text-decoration: none;
- color: inherit;
-}
-
-.octo-copy-md__trigger::-webkit-details-marker,
-.octo-copy-md__trigger::marker {
- content: '';
- display: none;
-}
-
-.octo-copy-md__trigger-icon,
-.octo-copy-md__trigger-caret {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
-}
-
-.octo-copy-md__trigger-icon::before {
- content: '\f0eb'; /* fa-lightbulb */
- font-family: fa-solid;
- font-size: 0.95em;
- line-height: 1;
- color: var(--color-menu-link-alt);
- transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.octo-copy-md__trigger-caret::before {
- content: '\f078'; /* fa-chevron-down */
- font-family: fa-solid;
- font-size: 0.7em;
- line-height: 1;
- color: currentColor;
- transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.octo-copy-md__menu[open]
- > .octo-copy-md__trigger
- .octo-copy-md__trigger-caret::before {
- transform: rotate(180deg);
-}
-
-.octo-copy-md__trigger:hover {
- color: var(--color-menu-link-active);
- border-color: var(--color-menu-link-alt);
-}
-
-.octo-copy-md__menu[open] > .octo-copy-md__trigger {
- color: var(--color-menu-link-active);
- border-color: var(--color-menu-link-alt);
- background-color: var(--bg-color-menu-open);
- box-shadow: 0 0.0625rem 0.25rem rgba(13, 128, 216, 0.08);
-}
-
-.octo-copy-md__trigger:focus-visible {
- outline: 2px solid var(--color-menu-link-alt);
- outline-offset: 2px;
-}
-
-.octo-copy-md .octo-copy-md__options {
- position: absolute;
- z-index: 10;
- inset-block-start: calc(100% + 0.5rem);
- inset-inline-start: 0;
- margin: 0;
- padding: 0.5rem;
- list-style: none;
- background-color: var(--bg-color-menu);
- border: 1px solid var(--border-color-menu-open);
- border-radius: 0.625rem;
- min-width: 20rem;
- box-sizing: border-box;
- overflow: hidden;
- box-shadow:
- 0 0.625rem 1.875rem rgba(15, 37, 53, 0.12),
- 0 0.125rem 0.375rem rgba(15, 37, 53, 0.06);
-}
-
-.octo-copy-md .octo-copy-md__options .octo-copy-md__option {
- display: inline-flex;
- align-items: center;
- gap: 0.625rem;
- padding: 0.625rem 0.75rem;
- border: none;
- border-radius: 0.375rem;
- background: transparent;
- color: var(--color-menu-link);
- font: inherit;
- text-align: start;
- text-decoration: none;
- cursor: pointer;
- transition:
- background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
- color 150ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.octo-copy-md .octo-copy-md__options .octo-copy-md__option:hover,
-.octo-copy-md .octo-copy-md__options .octo-copy-md__option:focus-visible {
- background-color: var(--bg-color-menu-open);
- color: var(--color-menu-link-active);
- outline: none;
-}
-
-.octo-copy-md__option::before {
- font-family: fa-solid;
- font-size: 0.95em;
- width: 1.1em;
- text-align: center;
- color: var(--color-menu-link-alt);
- flex-shrink: 0;
- transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.octo-copy-md__option--copy::before {
- content: '\f0c5'; /* fa-copy */
-}
-
-.octo-copy-md__option--view::before {
- content: '\f15c'; /* fa-file-lines */
-}
-
-.octo-copy-md__option--all::before {
- content: '\f02d'; /* fa-book */
-}
-
/* Live regions for announcing the result of an action, such as copying a URL or
a code block. Read by screen readers, never shown. */
-.octo-copy-md__sr-status,
.copy-status {
position: absolute;
width: 1px;
diff --git a/tests/llm-endpoints.spec.ts b/tests/llm-endpoints.spec.ts
index 06a3c4c447..5e19d4f85a 100644
--- a/tests/llm-endpoints.spec.ts
+++ b/tests/llm-endpoints.spec.ts
@@ -120,31 +120,84 @@ test.describe('Copy as markdown page action', () => {
});
});
-test('CopyMarkdown dropdown advertises a working .md URL on the eligible page', async ({
+test('Open in LLM hands every assistant a working .md URL', async ({
page,
request,
}) => {
await page.goto(STABLE_PLAIN_MD_PATH);
- const url = await page.getAttribute(
- '[data-copy-md-action="copy"]',
- 'data-copy-md-url'
- );
+
+ const links = await page
+ .locator('.octo-llm a[href]')
+ .evaluateAll((all) => all.map((l) => (l as HTMLAnchorElement).href));
+
expect(
- url,
- 'expected CopyMarkdown dropdown to expose a data-copy-md-url'
- ).toBeTruthy();
- const target = await request.get(url!);
+ 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('CopyMarkdown dropdown is hidden on the ineligible MDX page', async ({
- page,
-}) => {
+test('Open in LLM is hidden on the ineligible MDX page', async ({ page }) => {
await page.goto(STABLE_MDX_PATH);
- const count = await page.locator('[data-copy-md-menu]').count();
- expect(count, 'expected no CopyMarkdown dropdown on ineligible page').toBe(0);
+ const count = await page.locator('[data-split-menu]').count();
+ expect(count, 'expected no Open in LLM button on ineligible page').toBe(0);
});
+// This is where the split button's 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(`Open in LLM 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('[data-split-trigger]').click();
+
+ const menu = page.locator('.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);
+ });
+}
+
test('HtmlHead omits `` on the ineligible MDX page', async ({
page,
}) => {