From 67871aef7dad7950ce271a1c9826db5a0a7c5c3c Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Wed, 29 Jul 2026 18:48:00 +0200 Subject: [PATCH 1/4] feat(page-actions): add agent handoff prompt --- .github/workflows/validate-build.yml | 2 + package.json | 1 + src/js/13-agent-handoff.js | 108 ++++++++++++++++ src/js/14-markdown-dropdown.js | 62 +++++++++ src/partials/markdown-dropdown.hbs | 18 +++ tests/markdown-dropdown/agent-handoff.test.js | 120 ++++++++++++++++++ 6 files changed, 311 insertions(+) create mode 100644 src/js/13-agent-handoff.js create mode 100644 tests/markdown-dropdown/agent-handoff.test.js diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index df3c735f..e4f1bcd2 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -42,6 +42,8 @@ jobs: - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci + - name: Test page actions + run: npm run test:markdown-dropdown - name: Cache generated files uses: actions/cache@v4 with: diff --git a/package.json b/package.json index fea318c2..f3f7367f 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "vinyl-source-stream": "^2.0.0" }, "scripts": { + "test:markdown-dropdown": "node --test tests/markdown-dropdown/*.test.js", "test:headless": "node tests/bloblang-playground/test-runner.js", "test:playground": "npm run build:wasm && npm run test:headless", "test:interactive": "node tests/bloblang-interactive/test-runner.js", diff --git a/src/js/13-agent-handoff.js b/src/js/13-agent-handoff.js new file mode 100644 index 00000000..e4c68070 --- /dev/null +++ b/src/js/13-agent-handoff.js @@ -0,0 +1,108 @@ +;(function (root, factory) { + const agentHandoff = factory() + + if (typeof module === 'object' && module.exports) { + module.exports = agentHandoff + } else { + root.RedpandaDocsAgentHandoff = agentHandoff + } +})(typeof window === 'undefined' ? this : window, function () { + const headingPattern = /^(#{1,6})[ \t]+(.+?)\s*$/ + const componentExportPattern = + /Component-specific:[^\n]*\]\((https?:\/\/[^)\s]+-full\.txt)\)/ + + function normalizedAnchor (sectionAnchor) { + const rawAnchor = sectionAnchor.replace(/^#/, '') + + try { + return decodeURIComponent(rawAnchor) + } catch (error) { + return rawAnchor + } + } + + function extractMarkdownSection (markdown, sectionAnchor) { + const fullPage = markdown.trim() + if (!sectionAnchor) return fullPage + + const anchorMarker = `(#${normalizedAnchor(sectionAnchor)})` + const lines = fullPage.split('\n') + const startIndex = lines.findIndex((line) => headingPattern.test(line) && line.includes(anchorMarker)) + if (startIndex === -1) return fullPage + + const sectionLevel = lines[startIndex].match(headingPattern)[1].length + const endIndex = lines.findIndex((line, index) => { + if (index <= startIndex) return false + + const heading = line.match(headingPattern) + return heading && heading[1].length <= sectionLevel + }) + + return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join('\n').trim() + } + + function componentExportUrl (markdown) { + const match = markdown.match(componentExportPattern) + return match && match[1] + } + + function buildAgentHandoffPrompt ({ + docsOrigin, + markdown, + markdownUrl, + pageTitle, + pageUrl, + sectionAnchor = '', + sectionTitle = '', + }) { + const context = extractMarkdownSection(markdown, sectionAnchor) + const componentExport = componentExportUrl(markdown) + const scope = [ + `- Documentation page: ${pageTitle}`, + sectionTitle ? `- Current section: ${sectionTitle}` : null, + `- Page: ${pageUrl}`, + `- Markdown source: ${markdownUrl}`, + ].filter(Boolean) + const sources = [ + `- Documentation index: ${new URL('/llms.txt', docsOrigin).href}`, + componentExport ? `- Component documentation export: ${componentExport}` : null, + `- Documentation MCP server: ${new URL('/mcp', docsOrigin).href}`, + ].filter(Boolean) + const instructions = [ + "1. Read the current project's agent and contributor instructions before changing anything.", + '2. Inspect the project and identify where this documentation applies. Do not invent Redpanda commands, fields, or behavior.', + '3. If applicable, implement the smallest reversible change and preserve unrelated behavior. If not applicable, explain why and stop.', + '4. You may edit and test local files. Before destructive operations, external mutations, or changes to a live ' + + 'Redpanda environment, show the plan or diff and get my confirmation. Never expose credentials or secrets.', + "5. Run the project's relevant checks and any documented Redpanda validation or diff command.", + '6. Summarize the changes, verification, remaining manual steps, and any missing or conflicting documentation.', + ] + + return `# Apply this Redpanda documentation + +Work in the current project. Determine whether this guidance applies, then make the smallest safe update that keeps the project aligned with the current Redpanda pattern. + +## Scope + +${scope.join('\n')} + +## Authoritative Redpanda context + +${sources.join('\n')} + +## Instructions + +${instructions.join('\n')} + +## Documentation context + +--- BEGIN CURRENT DOCUMENTATION --- +${context} +--- END CURRENT DOCUMENTATION ---` + } + + return { + buildAgentHandoffPrompt, + extractMarkdownSection, + } +}) diff --git a/src/js/14-markdown-dropdown.js b/src/js/14-markdown-dropdown.js index b9728e86..2524058a 100644 --- a/src/js/14-markdown-dropdown.js +++ b/src/js/14-markdown-dropdown.js @@ -9,6 +9,8 @@ * - Click outside to close */ +const { buildAgentHandoffPrompt } = window.RedpandaDocsAgentHandoff + ;(function () { 'use strict' @@ -60,6 +62,14 @@ setTimeout(function () { setOpen(false) }, 2500) + } else if (action === 'copy-agent') { + handleCopyAgent(markdownUrl, item).then(function (didCopy) { + if (didCopy) { + setTimeout(function () { + setOpen(false) + }, 2500) + } + }) } else if (action === 'view') { handleView(markdownUrl) setOpen(false) @@ -155,6 +165,58 @@ ) } + function flashCopyStatus (button, message) { + const status = button.querySelector('[data-agent-handoff-status]') + if (status) status.textContent = message + + button.classList.add('clicked') + // Force reflow so the animation can restart. + button.offsetHeight // eslint-disable-line no-unused-expressions + button.classList.remove('clicked') + } + + /** + * Copy a complete agent handoff with the current documentation section inline. + */ + function handleCopyAgent (markdownUrl, button) { + return window + .fetch(markdownUrl) + .then(function (response) { + if (!response.ok) throw new Error(`Failed to fetch documentation (${response.status})`) + return response.text() + }) + .then(function (markdown) { + const pageUrl = window.location.href + const absoluteMarkdownUrl = new URL(markdownUrl, window.location.origin).href + const pageTitle = document.querySelector('h1.page')?.textContent?.trim() || document.title + const sectionAnchor = window.location.hash + const sectionId = sectionAnchor.replace(/^#/, '') + const sectionTitle = document.getElementById(sectionId)?.textContent?.trim() || '' + const prompt = buildAgentHandoffPrompt({ + docsOrigin: window.location.origin, + markdown, + markdownUrl: absoluteMarkdownUrl, + pageTitle, + pageUrl, + sectionAnchor, + sectionTitle, + }) + + return window.navigator.clipboard.writeText(prompt) + }) + .then( + function () { + flashCopyStatus(button, 'Copied!') + return true + }, + function (error) { + console.error('Could not copy agent handoff:', error) + flashCopyStatus(button, 'Could not copy. Try again.') + return false + } + ) + } + /** * Handle view in new tab */ diff --git a/src/partials/markdown-dropdown.hbs b/src/partials/markdown-dropdown.hbs index 357dba00..5fcbd67f 100644 --- a/src/partials/markdown-dropdown.hbs +++ b/src/partials/markdown-dropdown.hbs @@ -33,6 +33,24 @@ Copied! + + + {{#if (has-agent-handoff)}} + {{/if}}