diff --git a/packages/extension/public/SelectionCommandHub_new.png b/packages/extension/public/SelectionCommandHub_new.png new file mode 100644 index 00000000..d6a24eb6 Binary files /dev/null and b/packages/extension/public/SelectionCommandHub_new.png differ diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 633fc4af..f764af2e 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Bitte wählen Sie den Typ des Befehls, den Sie erstellen möchten" }, + "Option_commandType_hubLink": { + "message": "Befehle finden" + }, "Option_commandType_search_title": { "message": "Suchen" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 143cc3da..0b9b7bf6 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -359,6 +359,9 @@ "Option_commandType_description": { "message": "Please select the type of command you want to create" }, + "Option_commandType_hubLink": { + "message": "Find Commands" + }, "Option_commandType_search_title": { "message": "Search" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index f18df98f..903c4d2a 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor seleccione el tipo de comando que desea crear" }, + "Option_commandType_hubLink": { + "message": "Buscar Comandos" + }, "Option_commandType_search_title": { "message": "Búsqueda" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 1513d3f3..cad04293 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Veuillez sélectionner le type de commande que vous souhaitez créer" }, + "Option_commandType_hubLink": { + "message": "Rechercher des Commandes" + }, "Option_commandType_search_title": { "message": "Recherche" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 9e76ed86..69239095 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "कृपया उस कमांड का प्रकार चुनें जिसे आप बनाना चाहते हैं" }, + "Option_commandType_hubLink": { + "message": "कमांड खोजें" + }, "Option_commandType_search_title": { "message": "खोज" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 2d645b1c..0597fdcc 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Silakan pilih jenis perintah yang ingin Anda buat" }, + "Option_commandType_hubLink": { + "message": "Cari Perintah" + }, "Option_commandType_search_title": { "message": "Pencarian" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 7783179f..9812eefd 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Seleziona il tipo di comando che desideri creare" }, + "Option_commandType_hubLink": { + "message": "Cerca Comandi" + }, "Option_commandType_search_title": { "message": "Ricerca" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index b7575757..b3cc1964 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "作成するコマンドの種類を選択してください" }, + "Option_commandType_hubLink": { + "message": "コマンドを探す" + }, "Option_commandType_search_title": { "message": "検索" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index bd50bf0f..4bad7ab6 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "생성하려는 명령 유형을 선택하세요" }, + "Option_commandType_hubLink": { + "message": "명령 찾기" + }, "Option_commandType_search_title": { "message": "검색" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index ee087b83..6784ef86 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Sila pilih jenis arahan yang ingin anda cipta" }, + "Option_commandType_hubLink": { + "message": "Cari Arahan" + }, "Option_commandType_search_title": { "message": "Carian" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index d7b024e1..40564d93 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor, selecione o tipo de comando que deseja criar" }, + "Option_commandType_hubLink": { + "message": "Buscar Comandos" + }, "Option_commandType_search_title": { "message": "Pesquisa" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 142d2cc3..7074a4cb 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor, selecione o tipo de comando que deseja criar" }, + "Option_commandType_hubLink": { + "message": "Procurar Comandos" + }, "Option_commandType_search_title": { "message": "Pesquisa" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index a8d06f10..df41a820 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Пожалуйста, выберите тип команды, которую вы хотите создать" }, + "Option_commandType_hubLink": { + "message": "Найти команды" + }, "Option_commandType_search_title": { "message": "Поиск" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 8b83224b..7eee5568 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "请选择您要创建的命令类型" }, + "Option_commandType_hubLink": { + "message": "查找命令" + }, "Option_commandType_search_title": { "message": "搜索" }, diff --git a/packages/extension/src/action/aiPrompt.test.ts b/packages/extension/src/action/aiPrompt.test.ts index 7d57f7ce..9982ad13 100644 --- a/packages/extension/src/action/aiPrompt.test.ts +++ b/packages/extension/src/action/aiPrompt.test.ts @@ -59,6 +59,7 @@ const makeDomService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: [".ql-editor"], submitSelectors: ["button.send"], + copySelectors: [], selectorType: "css" as any, ...overrides, }) @@ -70,6 +71,7 @@ const makeQueryService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: ["#prompt-textarea"], submitSelectors: ["button#submit"], + copySelectors: [], selectorType: "css" as any, queryUrl: "https://chatgpt.com/?prompt=%s", autoSubmit: false, @@ -83,6 +85,7 @@ const makeAutoSubmitService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: ["div#ask-input"], submitSelectors: ["button[aria-label='Submit']"], + copySelectors: [], selectorType: "css" as any, queryUrl: "https://www.perplexity.ai/search/new?q=%s", autoSubmit: true, diff --git a/packages/extension/src/components/option/SettingForm.tsx b/packages/extension/src/components/option/SettingForm.tsx index e107db85..121a1da7 100644 --- a/packages/extension/src/components/option/SettingForm.tsx +++ b/packages/extension/src/components/option/SettingForm.tsx @@ -479,18 +479,20 @@ export function SettingForm({ className }: { className?: string }) { )} {startupMethod !== STARTUP_METHOD.CONTEXT_MENU && ( - - )} + <> + - + + + )}
@@ -576,25 +578,25 @@ export function SettingForm({ className }: { className?: string }) { )} {linkCommandMethod === LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD && ( - - )} + + )} { const buttonRef = useRef(null) - const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + const [status, setStatus] = useState<"idle" | "pending" | "sent" | "error">( + "idle", + ) - const handleClick = (e: React.MouseEvent) => { + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() if (isShared) { @@ -48,6 +51,10 @@ export const ShareButton = ({ return } + // Disable the button immediately to prevent duplicate shares/signup + // tabs from rapid repeated clicks while the storage lookup below runs. + setStatus("pending") + let commandToShare = command if (!isUUIDv7(command.id)) { const newId = generateId() @@ -56,16 +63,33 @@ export const ShareButton = ({ } const ok = shareCommandToHub(commandToShare) - setStatus(ok ? "sent" : "error") - setTimeout(() => setStatus("idle"), 2000) - if (ok) { - sendEvent( - ANALYTICS_EVENTS.COMMAND_SHARE, - { event_label: "share-button" }, - SCREEN.OPTION, - ) + if (!ok) { + setStatus("error") + setTimeout(() => setStatus("idle"), 2000) + return + } + + // Users who have never signed in to the hub are redirected to the + // sign-up page instead (see shareCommandToHub in services/hub/background.ts); + // nothing is actually shared yet, so leave the button idle and skip + // the share analytics event for this case. + const registered = await Storage.get( + LOCAL_STORAGE_KEY.HUB_REGISTERED, + ) + if (!registered) { + setStatus("idle") + return } + + setStatus("sent") + setTimeout(() => setStatus("idle"), 2000) + + sendEvent( + ANALYTICS_EVENTS.COMMAND_SHARE, + { event_label: "share-button" }, + SCREEN.OPTION, + ) } if ( diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index f17c2041..52d0d8b9 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -120,13 +120,13 @@ const getDefault = ( ) => { const sourceDefaults = isEmpty(base?.id) ? { - sourceType: base?.sourceType ?? COMMAND_SOURCE_TYPE.SELF_CREATED, - sourceId: base?.sourceId ?? COMMAND_SOURCE_ID.SELF_CREATED, - } + sourceType: base?.sourceType ?? COMMAND_SOURCE_TYPE.SELF_CREATED, + sourceId: base?.sourceId ?? COMMAND_SOURCE_ID.SELF_CREATED, + } : { - sourceType: base?.sourceType, - sourceId: base?.sourceId, - } + sourceType: base?.sourceType, + sourceId: base?.sourceId, + } if (isSearchOpenMode(openMode)) { if (isSearchOpenMode(preOpenMode)) { @@ -610,6 +610,7 @@ const CommandEditDialogInner = ({ const parsedUrl = parseGeminiUrl(value) if (parsedUrl !== value) { setValue("searchUrl", parsedUrl) + clearErrors("searchUrl") } field.onBlur() }} diff --git a/packages/extension/src/components/option/editor/CommandListMenu.tsx b/packages/extension/src/components/option/editor/CommandListMenu.tsx index f9050cac..26ab24bd 100644 --- a/packages/extension/src/components/option/editor/CommandListMenu.tsx +++ b/packages/extension/src/components/option/editor/CommandListMenu.tsx @@ -1,16 +1,11 @@ import React from "react" import { Button } from "@/components/ui/button" -import { Terminal, FolderPlus, Search } from "lucide-react" +import { Terminal, FolderPlus } from "lucide-react" import { Tooltip } from "@/components/Tooltip" -import { NEW_HUB_URL } from "@/const" import { t as _t } from "@/services/i18n" import { TEST_IDS } from "@/testIds" -import { useHubUser } from "@/hooks/option/useHubUser" -import { getHubLocale } from "@/services/hubShare" -import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" -const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) -const UTM_PARAMS = { source: UTM_SOURCE.OPTION_PAGE, medium: UTM_MEDIUM.BUTTON } +const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) interface Props { onAddCommand: () => void @@ -27,66 +22,44 @@ export const CommandListMenu: React.FC = ({ addFolderButtonRef, commandCount, }) => { - const hubUser = useHubUser() - const locale = getHubLocale() - const hubButtonLink = withUtmParams( - hubUser - ? `${NEW_HUB_URL}/${locale}/dashboard/commands` - : `${NEW_HUB_URL}/${locale}`, - UTM_PARAMS, - ) - return ( -
+
{commandCount ?? 0} {t("commands_desc_count")} - - - - - + + + + {t("Command")} + + +
) } diff --git a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx index 85b33909..726a0d26 100644 --- a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx @@ -1,3 +1,4 @@ +import { Search } from "lucide-react" import { Dialog, DialogContent, @@ -5,7 +6,16 @@ import { DialogHeader, DialogTitle, DialogPortal, + DialogFooter, + DialogClose, } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +import { NEW_HUB_URL } from "@/const" +import { useHubUser } from "@/hooks/option/useHubUser" +import { getHubLocale } from "@/services/hubShare" +import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" +const UTM_PARAMS = { source: UTM_SOURCE.OPTION_PAGE, medium: UTM_MEDIUM.BUTTON } import { CommandType } from "./CommandType" @@ -25,6 +35,15 @@ export const CommandTypeSelectionDialog = ({ onOpenChange, onSelect, }: CommandTypeSelectionDialogProps) => { + const hubUser = useHubUser() + const locale = getHubLocale() + const hubButtonLink = withUtmParams( + hubUser + ? `${NEW_HUB_URL}/${locale}/dashboard/commands` + : `${NEW_HUB_URL}/${locale}`, + UTM_PARAMS, + ) + const handleCardClick = (type: COMMAND_TYPE) => { onSelect(type) onOpenChange(false) @@ -34,12 +53,32 @@ export const CommandTypeSelectionDialog = ({ - - {t("commandType_title")} - - {t("commandType_description")} - - +
+ + {t("commandType_title")} + + {t("commandType_description")} + + + +
{COMMAND_TYPE_GROUPS.map((group) => (
@@ -59,6 +98,13 @@ export const CommandTypeSelectionDialog = ({
))}
+ + + + +
diff --git a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx index 797b7f44..0503d883 100644 --- a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx +++ b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx @@ -223,10 +223,24 @@ vi.mock("@/action/pageAction", () => ({ })) vi.mock("@/services/searchUrlAssist", () => ({ - searchUrlAssistAction: { + SEARCH_URL_ASSIST_SERVICE_ID: "gemini", + createSearchUrlAssistAction: vi.fn(() => ({ id: "search-url-assist", title: "Search URL Assist", - }, + })), +})) + +vi.mock("@/services/aiPrompt", () => ({ + findAiService: vi.fn().mockResolvedValue({ + id: "gemini", + name: "Gemini", + url: "https://gemini.google.com/app", + faviconUrl: "", + inputSelectors: [".ql-editor[contenteditable='true']"], + submitSelectors: ["[data-test-id='send-button-container'] button"], + copySelectors: ["copy-button button"], + selectorType: "css", + }), })) // Mock lib/utils diff --git a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx index 626fae5e..19672f4e 100644 --- a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx +++ b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx @@ -31,7 +31,11 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { t as _t } from "@/services/i18n" -import { searchUrlAssistAction } from "@/services/searchUrlAssist" +import { + createSearchUrlAssistAction, + SEARCH_URL_ASSIST_SERVICE_ID, +} from "@/services/searchUrlAssist" +import { findAiService } from "@/services/aiPrompt" import { PageAction } from "@/action/pageAction" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) @@ -71,7 +75,13 @@ export const SearchUrlAssistDialog = ({ setIsProcessing(true) try { - const command = searchUrlAssistAction + const service = await findAiService(SEARCH_URL_ASSIST_SERVICE_ID) + if (!service) { + console.error(`AI service not found: ${SEARCH_URL_ASSIST_SERVICE_ID}`) + return + } + + const command = createSearchUrlAssistAction(service) PageAction.execute({ selectionText: "", command, diff --git a/packages/extension/src/components/option/field/SwitchField.tsx b/packages/extension/src/components/option/field/SwitchField.tsx index 720fbff3..b02dab9d 100644 --- a/packages/extension/src/components/option/field/SwitchField.tsx +++ b/packages/extension/src/components/option/field/SwitchField.tsx @@ -37,10 +37,10 @@ export const SwitchField = ({ render={({ field }) => (
- - {formLabel} +
+ + {formLabel} + {tooltip && ( )} - +
{description && {description}} {tooltip && ( { faviconUrl: s.faviconUrl ?? "", inputSelectors: s.inputSelectors ?? [], submitSelectors: s.submitSelectors ?? [], + copySelectors: s.copySelectors ?? [], selectorType: s.selectorType ?? SelectorType.css, queryUrl: s.queryUrl, autoSubmit: s.autoSubmit, diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 20a4f95b..f58c8d36 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -21,9 +21,14 @@ vi.mock("@/services/storage", () => ({ getCommands: vi.fn(), setCommands: vi.fn(), set: vi.fn(), + get: vi.fn(), updateCommands: vi.fn(), }, - LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", HUB_SHARED_AT: "hubSharedAt" }, + LOCAL_STORAGE_KEY: { + HUB_USER: "hubUser", + HUB_SHARED_AT: "hubSharedAt", + HUB_REGISTERED: "hubRegistered", + }, })) vi.mock("@/services/settings/settings", () => ({ @@ -123,6 +128,7 @@ beforeEach(() => { vi.mocked(Settings.updateCommandId).mockResolvedValue(undefined) vi.mocked(Storage.setCommands).mockResolvedValue(true) vi.mocked(Storage.set).mockResolvedValue(true) + vi.mocked(Storage.get).mockResolvedValue(true) vi.mocked(sendEvent).mockResolvedValue(undefined as any) vi.mocked(getOrCreateClientId).mockResolvedValue("client-id-123") ;(chrome.tabs as any).remove = vi.fn() @@ -936,6 +942,23 @@ describe("shareCommandToHub", () => { ) }) + it("SH-02b: opens the sign-up page instead of the dashboard, and skips the port handshake, when the user has never registered", async () => { + vi.mocked(Storage.get).mockResolvedValue(false) + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(true)) + + expect(chrome.tabs.create).toHaveBeenCalledWith( + { url: `${HUB_ORIGIN}/auth/signup` }, + expect.any(Function), + ) + expect(chrome.runtime.onConnectExternal.addListener).not.toHaveBeenCalled() + }) + it("SH-03: calls response(false) when tab.id is undefined", async () => { vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { cb?.({} as chrome.tabs.Tab) @@ -964,6 +987,8 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + await Promise.resolve() + const portConnectListener = vi.mocked( chrome.runtime.onConnectExternal.addListener, ).mock.calls[0][0] @@ -1013,6 +1038,9 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check, one for the tab-creation + // await that assigns tabId before the port-connect handler can match it. + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1057,6 +1085,9 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check, one for the tab-creation + // await that assigns tabId before the port-connect handler can match it. + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1107,6 +1138,9 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check, one for the tab-creation + // await that assigns tabId before the port-connect handler can match it. + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1159,6 +1193,9 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check, one for the tab-creation + // await that assigns tabId before the port-connect handler can match it. + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 5a00ce23..78293b51 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -79,6 +79,23 @@ export const shareCommandToHub = ( const share = async () => { try { + const registered = await Storage.get( + LOCAL_STORAGE_KEY.HUB_REGISTERED, + ) + if (!registered) { + // Users who have never signed in to the hub have no account to + // share into yet. Send them to sign up instead of the dashboard, + // which would otherwise just bounce them to the login screen. + // The "hub-share" port handshake below is only implemented on the + // dashboard page, so it can't succeed here and is skipped. + const signupUrl = `${NEW_HUB_URL}/auth/signup` + const tab = await new Promise((resolve) => + chrome.tabs.create({ url: signupUrl }, resolve), + ) + response(!!tab?.id) + return + } + let currentParam = param let idRegenerateCount = 0 const MAX_ID_REGENERATE = 3 @@ -308,36 +325,36 @@ export async function handleAddCommand( const cmd = isSearch ? { - id: parsed.id, - title: parsed.title, - searchUrl: parsed.searchUrl, - iconUrl: parsed.iconUrl, - ...sourceInfo, - openMode: parsed.openMode, - openModeSecondary: parsed.openModeSecondary, - spaceEncoding: parsed.spaceEncoding, - popupOption: PopupOption, - } - : isAiPrompt - ? { id: parsed.id, title: parsed.title, + searchUrl: parsed.searchUrl, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - aiPromptOption: parsed.aiPromptOption, + openModeSecondary: parsed.openModeSecondary, + spaceEncoding: parsed.spaceEncoding, popupOption: PopupOption, } - : isPageAction - ? { + : isAiPrompt + ? { id: parsed.id, title: parsed.title, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - pageActionOption: parsed.pageActionOption, + aiPromptOption: parsed.aiPromptOption, popupOption: PopupOption, } + : isPageAction + ? { + id: parsed.id, + title: parsed.title, + iconUrl: parsed.iconUrl, + ...sourceInfo, + openMode: parsed.openMode, + pageActionOption: parsed.pageActionOption, + popupOption: PopupOption, + } : null if (!cmd) { @@ -416,7 +433,7 @@ export function handleEditCommand( ackTimeout: undefined, ackListener: undefined, pendingResponse: undefined, - cancelConnectWait: () => { }, + cancelConnectWait: () => {}, } _editSession = newSession @@ -515,6 +532,9 @@ export async function handleSetSession( image: "", } await Storage.set(LOCAL_STORAGE_KEY.HUB_USER, hubUser) + // Once authenticated, remember it permanently (not cleared on sign-out) + // so the extension can tell "signed up before" apart from "signed in now". + await Storage.set(LOCAL_STORAGE_KEY.HUB_REGISTERED, true) sendResponse({ result: true }) } catch (err) { console.error("[handleSetSession] Failed:", err) diff --git a/packages/extension/src/services/searchUrlAssist.ts b/packages/extension/src/services/searchUrlAssist.ts index e58afd25..d3452a24 100644 --- a/packages/extension/src/services/searchUrlAssist.ts +++ b/packages/extension/src/services/searchUrlAssist.ts @@ -1,84 +1,116 @@ -import { PAGE_ACTION_OPEN_MODE, OPEN_MODE } from "@/const" +import { + PAGE_ACTION_OPEN_MODE, + OPEN_MODE, + PAGE_ACTION_CONTROL, + PAGE_ACTION_EVENT, + SelectorType, +} from "@/const" +import type { AiService } from "@/types" -export const searchUrlAssistAction = { - title: "Search Assist AI", - id: "0bf16427-ff9d-456c-b505-67b468c781a7", - revision: 0, - iconUrl: - "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", - openMode: OPEN_MODE.PAGE_ACTION, - popupOption: { - height: 600, - width: 1000, - }, - pageActionOption: { - openMode: PAGE_ACTION_OPEN_MODE.POPUP, - startUrl: "https://gemini.google.com/app", - steps: [ - { - delayMs: 0, - id: "iiaew61ok", - param: { - label: "Start", - type: "start", +/** id of the Gemini entry in ai-services.json, used to look up its selectors. */ +export const SEARCH_URL_ASSIST_SERVICE_ID = "gemini" + +const PROMPT_TEMPLATE = + "User Input: Given a search result URL and the entered search keyword, generate a search URL template.\n\n# Generation steps\n1. Replace the value of the search keyword parameter with %s.\n2. Remove unnecessary query parameters that are unrelated to the search itself (e.g., ad tracking IDs, session IDs). Keep parameters required for functionality such as language or search settings.\n\n# User Input\n* Search keyword: {{search_keyword}}\n* Search result URL: {{search_result_url}}\n\n# Example of generation\n* Search keyword: test\n* Search result URL: https://www.google.com/search?q=test&rlz=1C5CHFA_enJP1116JP1116&oq=test&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPTIGCAMQRRhBMgYIBBBFGDzSAQgxOTE0ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8\n* Search URL template: https://google.com/search?q=%s\n\n# Output format\nOutput only the search URL template in plain text. Do not include anything else.\nExample of valid output:\nhttps://google.com/search?q=%s" + +// Used when `service.copySelectors` is missing (e.g. a same-day cached +// AiService fetched before this field was introduced), so the step never +// ends up with an empty selector. +const FALLBACK_COPY_SELECTOR = "copy-button button" + +/** + * Build the Search URL Assist PageAction command for Gemini. + * Input/submit selectors are taken from `service` (sourced from + * ai-services.json) instead of being hardcoded, so this stays working + * across Gemini UI changes as long as ai-services.json is kept up to date. + */ +export const createSearchUrlAssistAction = (service: AiService) => { + const inputSelector = service.inputSelectors.join(", ") + const submitSelector = service.submitSelectors.join(", ") + + const copySelectors = service.copySelectors ?? [] + const copySelector = + copySelectors.length > 0 ? copySelectors.join(", ") : FALLBACK_COPY_SELECTOR + + return { + title: "Search Assist AI", + id: "0bf16427-ff9d-456c-b505-67b468c781a7", + revision: 0, + iconUrl: + "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", + openMode: OPEN_MODE.PAGE_ACTION, + popupOption: { + height: 600, + width: 1000, + }, + pageActionOption: { + openMode: PAGE_ACTION_OPEN_MODE.POPUP, + startUrl: service.url, + steps: [ + { + delayMs: 0, + id: "iiaew61ok", + param: { + label: "Start", + type: PAGE_ACTION_CONTROL.start, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "hbbcdmvz3", - param: { - label: "Focus textarea", - selector: "//*[@role='textbox']/*", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "hbbcdmvz3", + param: { + label: "Focus textarea", + selector: inputSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "5f9h14pq1", - param: { - label: "Input prompt", - selector: "//*[@role='textbox']/*", - selectorType: "xpath", - type: "input", - value: - "User Input: Given a search result URL and the entered search keyword, generate a search URL template.\n\n# Generation steps\n1. Replace the value of the search keyword parameter with %s.\n2. Remove unnecessary query parameters that are unrelated to the search itself (e.g., ad tracking IDs, session IDs). Keep parameters required for functionality such as language or search settings.\n\n# User Input\n* Search keyword: {{search_keyword}}\n* Search result URL: {{search_result_url}}\n\n# Example of generation\n* Search keyword: test\n* Search result URL: https://www.google.com/search?q=test&rlz=1C5CHFA_enJP1116JP1116&oq=test&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPTIGCAMQRRhBMgYIBBBFGDzSAQgxOTE0ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8\n* Search URL template: https://google.com/search?q=%s\n\n# Output format\nOutput only the search URL template in plain text. Do not include anything else.\nExample of valid output:\nhttps://google.com/search?q=%s", + { + delayMs: 0, + id: "5f9h14pq1", + param: { + label: "Input prompt", + selector: inputSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.input, + value: PROMPT_TEMPLATE, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "cnkcck292", - param: { - label: "Submit", - selector: "//*[@data-mat-icon-name='send']", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "cnkcck292", + param: { + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "2c30nv8o2", - param: { - label: "Copy to clipboard", - selector: "//*[@data-mat-icon-name='content_copy']", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "2c30nv8o2", + param: { + label: "Copy to clipboard", + selector: copySelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "f7nb0ge9b", - param: { - label: "End", - type: "end", + { + delayMs: 0, + id: "f7nb0ge9b", + param: { + label: "End", + type: PAGE_ACTION_CONTROL.end, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - ], - }, + ], + }, + } } diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index e449e871..2a59c8bb 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -18,6 +18,7 @@ export enum LOCAL_STORAGE_KEY { WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup", HUB_USER = "hubUser", HUB_SHARED_AT = "hubSharedAt", + HUB_REGISTERED = "hubRegistered", } export enum SESSION_STORAGE_KEY { diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 0b24e70d..52f1540b 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -74,6 +74,7 @@ const DEFAULTS = { [LOCAL_STORAGE_KEY.LOCAL_COMMAND_METADATA]: null, [LOCAL_STORAGE_KEY.GLOBAL_COMMAND_METADATA]: null, [LOCAL_STORAGE_KEY.HUB_USER]: null, + [LOCAL_STORAGE_KEY.HUB_REGISTERED]: false, [SESSION_STORAGE_KEY.BG]: {}, [SESSION_STORAGE_KEY.SESSION_DATA]: null, [SESSION_STORAGE_KEY.MESSAGE_QUEUE]: [], diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 67c3d92f..afc312b0 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -339,6 +339,8 @@ export type AiService = { faviconUrl: string inputSelectors: string[] submitSelectors: string[] + /** Selectors for the button that copies the service's generated response to the clipboard. */ + copySelectors: string[] selectorType: SelectorType /** URL template with %s placeholder for prompt injection via URL query parameter. */ queryUrl?: string diff --git a/packages/hub/public/data/ai-services.json b/packages/hub/public/data/ai-services.json index 959fe568..30834a01 100644 --- a/packages/hub/public/data/ai-services.json +++ b/packages/hub/public/data/ai-services.json @@ -6,10 +6,7 @@ "queryUrl": "https://chatgpt.com/#?q=%s", "autoSubmit": true, "faviconUrl": "https://chatgpt.com/favicon.ico", - "inputSelectors": [ - "#prompt-textarea", - "[data-testid='prompt-textarea']" - ], + "inputSelectors": ["#prompt-textarea", "[data-testid='prompt-textarea']"], "submitSelectors": [ "form button.composer-submit-button-color", "button#composer-submit-button", @@ -21,13 +18,15 @@ "name": "Gemini", "url": "https://gemini.google.com/app", "faviconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", - "inputSelectors": [ - ".ql-editor[contenteditable='true']" - ], + "inputSelectors": [".ql-editor[contenteditable='true']"], "submitSelectors": [ "[data-test-id='send-button-container'] button", "button.send-button", "button mat-icon[fonticon='send']" + ], + "copySelectors": [ + "copy-button button", + "message-actions button[aria-label='Copy']" ] }, {