diff --git a/packages/extension/src/action/aiPrompt.test.ts b/packages/extension/src/action/aiPrompt.test.ts
index 03403796..b067112c 100644
--- a/packages/extension/src/action/aiPrompt.test.ts
+++ b/packages/extension/src/action/aiPrompt.test.ts
@@ -4,6 +4,7 @@ import { Ipc, BgCommand } from "@/services/ipc"
import { findAiService } from "@/services/aiPrompt"
import { Storage } from "@/services/storage"
import {
+ APP_ID,
OPEN_MODE,
PAGE_ACTION_EVENT,
PAGE_ACTION_CONDITION_ACTION,
@@ -248,6 +249,44 @@ describe("AiPrompt.execute", () => {
expect(payload.selectionHtml).toBeUndefined()
})
+ it("AP-01c-2: pageHtml should exclude the extension's own injected UI containers", async () => {
+ vi.mocked(findAiService).mockResolvedValue(makeDomService())
+
+ const rootDom = document.createElement("div")
+ rootDom.id = APP_ID
+ document.body.appendChild(rootDom)
+
+ const hubDom = document.createElement("div")
+ hubDom.id = `${APP_ID}-hub`
+ document.body.appendChild(hubDom)
+
+ try {
+ await AiPrompt.execute({
+ selectionText: "hello world",
+ command: {
+ ...baseCommand,
+ aiPromptOption: {
+ ...baseCommand.aiPromptOption,
+ serviceId: "gemini",
+ prompt: toInsertTemplate(INSERT.PAGE_HTML),
+ },
+ } as any,
+ position: { x: 100, y: 100 },
+ })
+
+ const call = vi
+ .mocked(Ipc.send)
+ .mock.calls.find(([cmd]) => cmd === BgCommand.openAndRunPageAction)
+ const payload = call?.[1] as any
+
+ expect(payload.pageHtml).not.toContain(`id="${APP_ID}"`)
+ expect(payload.pageHtml).not.toContain(`id="${APP_ID}-hub"`)
+ } finally {
+ rootDom.remove()
+ hubDom.remove()
+ }
+ })
+
it("AP-01d: should still attach SELECTION_HTML when the prompt contains only SELECTION_HTML", async () => {
vi.mocked(findAiService).mockResolvedValue(makeDomService())
diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts
index 0e6e9588..8f986079 100644
--- a/packages/extension/src/action/aiPrompt.ts
+++ b/packages/extension/src/action/aiPrompt.ts
@@ -22,15 +22,10 @@ import type { OpenAndRunProps } from "@/services/pageAction/background"
import type { OpenSidePanelProps } from "@/services/chrome"
import { findAiService } from "@/services/aiPrompt"
import { isAiPromptType } from "@/types/schema"
-import {
- INSERT,
- InsertSymbol,
- toInsertTemplate,
- PAGE_HTML_MAX_CHARS,
-} from "@/services/pageAction"
+import { INSERT, InsertSymbol, toInsertTemplate } from "@/services/pageAction"
import { Storage, SESSION_STORAGE_KEY } from "@/services/storage"
import { getUILanguage } from "@/services/i18n"
-import { getSelectionHtml } from "@/services/dom"
+import { getSelectionHtml, getPageHtml } from "@/services/dom"
/**
* Convert bare URLs in text to Markdown link format [URL](URL).
@@ -104,9 +99,7 @@ export const AiPrompt = {
const needPageHtml = aiPromptOption.prompt.includes(
toInsertTemplate(INSERT.PAGE_HTML),
)
- const pageHtml = needPageHtml
- ? document.documentElement.outerHTML.slice(0, PAGE_HTML_MAX_CHARS)
- : undefined
+ const pageHtml = needPageHtml ? getPageHtml() : undefined
// Only one HTML attachment is allowed per execution; PAGE_HTML takes
// priority over SELECTION_HTML when the prompt contains both.
@@ -297,9 +290,9 @@ export const AiPrompt = {
// Remove the HTML placeholders from the prompt value.
const promptValue = needFilePaste
? aiPromptOption.prompt
- .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "")
- .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "")
- .trim()
+ .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "")
+ .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "")
+ .trim()
: aiPromptOption.prompt
steps = [
diff --git a/packages/extension/src/command_hub.tsx b/packages/extension/src/command_hub.tsx
index cc6d791b..565ac5ec 100644
--- a/packages/extension/src/command_hub.tsx
+++ b/packages/extension/src/command_hub.tsx
@@ -26,7 +26,7 @@ const cloneCss = (from: ShadowRoot, to: ShadowRoot, selector: string) => {
function setupCommandHub() {
const rootDom = document.createElement("div")
- rootDom.id = `${APP_ID}-command-hub`
+ rootDom.id = `${APP_ID}-hub`
document.body.insertAdjacentElement("afterend", rootDom)
const shadow = rootDom.attachShadow({ mode })
const root = createRoot(shadow)
@@ -45,9 +45,7 @@ function renderMyCommands() {
container.style.display = "block"
if (isDebug) {
- const from = document.getElementById(
- "selection-command-command-hub",
- )?.shadowRoot
+ const from = document.getElementById(`${APP_ID}-hub`)?.shadowRoot
from && cloneCss(from, shadow, "style")
} else {
insertCss(shadow)
diff --git a/packages/extension/src/components/pageAction/InputPopup.tsx b/packages/extension/src/components/pageAction/InputPopup.tsx
index d4a8e5c2..54d59459 100644
--- a/packages/extension/src/components/pageAction/InputPopup.tsx
+++ b/packages/extension/src/components/pageAction/InputPopup.tsx
@@ -156,7 +156,7 @@ const useVisibleDelay = (props: useDelayProps) => {
return () => {
clearTimeout(delayTimer)
}
- }, [visible])
+ }, [visible, updater])
}
export function InputPopup(): JSX.Element {
@@ -173,16 +173,17 @@ export function InputPopup(): JSX.Element {
setTargetElm(getFocusNode(e))
}
- const onFocusIn = (e: any) => {
+ const onFocusIn = (e: FocusEvent) => {
+ if (!e.target) return
if (!isTargetEditable(e.target)) return
- if (isPopup(e.target)) return
+ if (isPopup(e.target as Element)) return
setMenuVisible(true)
updateTarget(e)
}
- const onFocusOut = (e: any) => {
+ const onFocusOut = (e: FocusEvent) => {
if (!isTargetEditable(e.target)) return
- if (isPopup(e.relatedTarget)) return
+ if (isPopup(e.relatedTarget as Element)) return
setMenuVisible(false)
setTargetElm(null)
}
@@ -195,7 +196,7 @@ export function InputPopup(): JSX.Element {
if (isHtmlElement(e.target)) {
setDisabled(
e.target.children.length > 0 &&
- e.target.innerText.trim().length !== 0,
+ e.target.innerText.trim().length !== 0,
)
}
}
@@ -287,7 +288,7 @@ export function InputMenu(props: MenuProps): JSX.Element {
return (
@@ -332,40 +333,40 @@ export function InputMenu(props: MenuProps): JSX.Element {
- {!props.hideFilePaste && (
-
-
+
+
+ onSelectedMenuChange(MENU.FILE_PASTE)}
- >
- {t("PageAction_InputMenu_fileAttach")}
- {!showFileAttachMenu &&
- (selectedMenu === MENU.FILE_PASTE ? (
-
- ) : (
-
- ))}
-
- setSelectedMenu("")}
- sideOffset={2}
- >
-
-
- {t("PageAction_InputMenu_pageHtml")}
-
-
-
- {t("PageAction_InputMenu_selectionHtml")}
-
-
-
+ )}
+ disabled={props.fileAttachDisabled}
+ onMouseEnter={() => onSelectedMenuChange(MENU.FILE_PASTE)}
+ >
+ {t("PageAction_InputMenu_fileAttach")}
+
+ setSelectedMenu("")}
+ sideOffset={2}
+ >
+
+
+ {t("PageAction_InputMenu_pageHtml")}
+
+
+
+ {t("PageAction_InputMenu_selectionHtml")}
+
+
+
+ >
)}
)
@@ -438,7 +439,7 @@ function FocusOutline(props: FocusOutlineProps): JSX.Element {
requestAnimationFrame(() => {
setRect(container?.getBoundingClientRect())
})
- }, [shouldRender])
+ }, [container, shouldRender])
if (elm == null || rect == null || !visible) return <>>
diff --git a/packages/extension/src/services/dom/index.test.ts b/packages/extension/src/services/dom/index.test.ts
index ea9e2d4d..dc1dbf77 100644
--- a/packages/extension/src/services/dom/index.test.ts
+++ b/packages/extension/src/services/dom/index.test.ts
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { APP_ID } from "@/const"
import {
getSelectionText,
getInputSelectionEndPoint,
getEditableSelectionEndPoint,
+ getPageHtml,
isInputOrTextarea,
isEditable,
} from "./index"
@@ -242,6 +244,69 @@ describe("getEditableSelectionEndPoint", () => {
})
})
+describe("getPageHtml", () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ it("GPH-01: excludes the content script's injected container", () => {
+ const marker = document.createElement("p")
+ marker.textContent = "page content"
+ document.body.appendChild(marker)
+
+ const rootDom = document.createElement("div")
+ rootDom.id = APP_ID
+ document.body.appendChild(rootDom)
+
+ const html = getPageHtml()
+ expect(html).not.toContain(`id="${APP_ID}"`)
+ expect(html).toContain("page content")
+ })
+
+ it("GPH-02: excludes the Command Hub's injected container", () => {
+ const marker = document.createElement("p")
+ marker.textContent = "page content"
+ document.body.appendChild(marker)
+
+ const hubDom = document.createElement("div")
+ hubDom.id = `${APP_ID}-hub`
+ document.body.appendChild(hubDom)
+
+ const html = getPageHtml()
+ expect(html).not.toContain(`id="${APP_ID}-hub"`)
+ expect(html).toContain("page content")
+ })
+
+ it("GPH-03: excludes both injected containers when present together", () => {
+ const marker = document.createElement("p")
+ marker.textContent = "page content"
+ document.body.appendChild(marker)
+
+ const rootDom = document.createElement("div")
+ rootDom.id = APP_ID
+ document.body.appendChild(rootDom)
+
+ const hubDom = document.createElement("div")
+ hubDom.id = `${APP_ID}-hub`
+ document.body.appendChild(hubDom)
+
+ const html = getPageHtml()
+ expect(html).not.toContain(`id="${APP_ID}"`)
+ expect(html).not.toContain(`id="${APP_ID}-hub"`)
+ expect(html).toContain("page content")
+ })
+
+ it("GPH-04: returns page HTML unchanged when no injected containers exist", () => {
+ const marker = document.createElement("p")
+ marker.textContent = "page content"
+ document.body.appendChild(marker)
+
+ const html = getPageHtml()
+ expect(html).toContain("page content")
+ expect(html).toContain(" {
it("IIOT-01: returns true for text input", () => {
const input = document.createElement("input")
diff --git a/packages/extension/src/services/dom/index.ts b/packages/extension/src/services/dom/index.ts
index 08d53b44..52fa1e90 100644
--- a/packages/extension/src/services/dom/index.ts
+++ b/packages/extension/src/services/dom/index.ts
@@ -1,7 +1,7 @@
import type { Point } from "@/types"
import { isEmpty } from "@/lib/utils"
-import { PAGE_HTML_MAX_CHARS } from "@/const"
+import { APP_ID, PAGE_HTML_MAX_CHARS } from "@/const"
export function toDataURL(src: string, outputFormat?: string): Promise {
return new Promise((resolve, reject) => {
@@ -104,6 +104,18 @@ export function getSelectionHtml(): string {
return div.innerHTML.slice(0, PAGE_HTML_MAX_CHARS)
}
+/**
+ * Get the page's outer HTML, excluding the extension's own injected UI
+ * containers (content script root and Command Hub root) so they aren't
+ * sent along as page content.
+ */
+export function getPageHtml(): string {
+ const clone = document.documentElement.cloneNode(true) as HTMLElement
+ clone.querySelector(`#${APP_ID}`)?.remove()
+ clone.querySelector(`#${APP_ID}-hub`)?.remove()
+ return clone.outerHTML.slice(0, PAGE_HTML_MAX_CHARS)
+}
+
/**
* Find the anchor element from the specified element.
*
diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts
index 055c6df0..9e57c363 100644
--- a/packages/extension/vite.config.ts
+++ b/packages/extension/vite.config.ts
@@ -27,23 +27,23 @@ export default defineConfig(({ mode }) => {
const activeManifest = isProduction
? {
- ...manifest,
- ...(extensionKey ? { key: extensionKey } : {}),
- content_scripts: manifest.content_scripts.map((cs) => ({
- ...cs,
- matches: cs.matches.filter((m) => !m.includes("localhost")),
- })),
- externally_connectable: {
- ...manifest.externally_connectable,
- matches: manifest.externally_connectable.matches.filter(
- (m) => !m.includes("localhost"),
- ),
- },
- }
+ ...manifest,
+ ...(extensionKey ? { key: extensionKey } : {}),
+ content_scripts: manifest.content_scripts.map((cs) => ({
+ ...cs,
+ matches: cs.matches.filter((m) => !m.includes("localhost")),
+ })),
+ externally_connectable: {
+ ...manifest.externally_connectable,
+ matches: manifest.externally_connectable.matches.filter(
+ (m) => !m.includes("localhost"),
+ ),
+ },
+ }
: {
- ...manifest,
- ...(extensionKey ? { key: extensionKey } : {}),
- }
+ ...manifest,
+ ...(extensionKey ? { key: extensionKey } : {}),
+ }
const plugins = [
react(),
@@ -99,7 +99,7 @@ export default defineConfig(({ mode }) => {
if (
options.attributes["data-vite-dev-id"].match(/command_hub/)
) {
- targetId = "selection-command-command-hub"
+ targetId = "selection-command-hub"
}
const root =
document.getElementById(targetId)?.shadowRoot ?? document.head // Option page
@@ -149,20 +149,20 @@ export default defineConfig(({ mode }) => {
pure:
mode === "production"
? [
- "console.log",
- "console.debug",
- "console.info",
- "console.trace",
- "console.dir",
- "console.count",
- "console.countReset",
- "console.group",
- "console.groupCollapsed",
- "console.groupEnd",
- "console.time",
- "console.timeEnd",
- "console.timeLog",
- ]
+ "console.log",
+ "console.debug",
+ "console.info",
+ "console.trace",
+ "console.dir",
+ "console.count",
+ "console.countReset",
+ "console.group",
+ "console.groupCollapsed",
+ "console.groupEnd",
+ "console.time",
+ "console.timeEnd",
+ "console.timeLog",
+ ]
: [],
},
build: {