From e3366378044f407959222d1f79252207371dedf1 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 12:09:38 +0900 Subject: [PATCH 01/37] =?UTF-8?q?Update:=20=E3=82=B3=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=82=AD=E3=82=B9=E3=83=88=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=81=A8=E3=81=8D=E3=81=AF=E3=83=A1=E3=83=8B=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E7=B6=AD=E6=8C=81=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=92=E9=9D=9E=E8=A1=A8=E7=A4=BA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/option/SettingForm.tsx | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) 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 && ( - - )} + + )} Date: Sat, 8 Aug 2026 12:10:25 +0900 Subject: [PATCH 02/37] =?UTF-8?q?fix:=20=E3=83=84=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=83=81=E3=83=83=E3=83=97=E3=81=AE=E6=8A=BC=E4=B8=8B=E3=81=A7?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E5=A4=89=E6=9B=B4=E3=81=8C=E7=99=BA=E7=94=9F?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/option/field/SwitchField.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 && ( Date: Sat, 8 Aug 2026 12:58:56 +0900 Subject: [PATCH 03/37] =?UTF-8?q?Fix:=20=E6=A4=9C=E7=B4=A2URL=E3=82=A2?= =?UTF-8?q?=E3=82=B7=E3=82=B9=E3=83=88=E3=81=AEGemini=E8=A6=81=E7=B4=A0?= =?UTF-8?q?=E6=8E=A2=E7=B4=A2=E3=82=92ai-services.json=E7=94=B1=E6=9D=A5?= =?UTF-8?q?=E3=81=AE=E3=82=BB=E3=83=AC=E3=82=AF=E3=82=BF=E3=83=BC=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini側のUI変更でハードコードしたXPathセレクターが要素探索に失敗していたため、 ai-services.jsonでメンテナンスしている入力欄・送信ボタン・コピーボタンの セレクターを実行時に取得して使用するようにした。コピー用セレクターが 未取得の場合(旧キャッシュ等)は現行のCSSセレクターにフォールバックする。 Co-Authored-By: Claude Sonnet 5 --- .../extension/src/action/aiPrompt.test.ts | 3 + .../editor/SearchUrlAssistDialog.test.tsx | 18 +- .../option/editor/SearchUrlAssistDialog.tsx | 14 +- .../src/services/aiPromptFallback.ts | 1 + .../extension/src/services/searchUrlAssist.ts | 184 ++++++++++-------- packages/extension/src/types/index.ts | 2 + packages/hub/public/data/ai-services.json | 13 +- 7 files changed, 148 insertions(+), 87 deletions(-) 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/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/services/aiPromptFallback.ts b/packages/extension/src/services/aiPromptFallback.ts index e36afc11..37c7a933 100644 --- a/packages/extension/src/services/aiPromptFallback.ts +++ b/packages/extension/src/services/aiPromptFallback.ts @@ -31,6 +31,7 @@ export const normalizeServices = (raw: unknown[]): AiService[] => { 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/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/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']" ] }, { From 4451d9eddab1d98e34580810bc1edb4aaba0bf4d Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 13:13:37 +0900 Subject: [PATCH 04/37] =?UTF-8?q?Fix:=20=E6=A4=9C=E7=B4=A2URL=E6=AC=84?= =?UTF-8?q?=E3=81=A7Gemini=20URL=E5=A4=89=E6=8F=9B=E5=BE=8C=E3=81=ABzod?= =?UTF-8?q?=E3=81=AE=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=8C=E6=AE=8B=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchUrl入力欄のonBlurでGeminiのMarkdown形式URLを生URLに変換した際、 setValueだけでは古い値に対するzodエラーが残ったままになっていたため、 clearErrorsを呼んでエラー表示をリセットするようにした。 Co-Authored-By: Claude Sonnet 5 --- .../components/option/editor/CommandEditDialog.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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() }} From 718b5dd0d923fadbc8c37fbf29b7fc5e0742c117 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 15:00:08 +0900 Subject: [PATCH 05/37] Add: Toast prompting Hub share on new command creation Shows a one-time toast on the options page after a command is created, inviting the user to share it to the Selection Command Hub. Once shown, it is never shown again (hasShownHubShareToast flag), following the same pattern as the existing review-request toast. Closes #443 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EJVeN7n8jzatkUy99SGyhG --- .../public/_locales/de/messages.json | 9 +++ .../public/_locales/en/messages.json | 11 +++ .../public/_locales/es/messages.json | 9 +++ .../public/_locales/fr/messages.json | 9 +++ .../public/_locales/hi/messages.json | 9 +++ .../public/_locales/id/messages.json | 9 +++ .../public/_locales/it/messages.json | 9 +++ .../public/_locales/ja/messages.json | 9 +++ .../public/_locales/ko/messages.json | 9 +++ .../public/_locales/ms/messages.json | 9 +++ .../public/_locales/pt_BR/messages.json | 9 +++ .../public/_locales/pt_PT/messages.json | 9 +++ .../public/_locales/ru/messages.json | 9 +++ .../public/_locales/zh_CN/messages.json | 9 +++ .../src/components/option/HubShareToast.tsx | 73 +++++++++++++++++++ .../src/components/option/ImportExport.tsx | 2 + .../src/components/option/Option.tsx | 2 + .../src/components/option/ShareButton.tsx | 31 ++------ .../components/option/editor/CommandList.tsx | 13 +++- .../extension/src/hooks/useSettings.test.tsx | 2 + packages/extension/src/services/hubShare.ts | 34 ++++++++- .../src/services/option/defaultSettings.ts | 1 + .../src/services/settings/enhancedSettings.ts | 51 +++++++------ .../src/services/settings/settings.test.ts | 3 + .../src/services/settings/settings.ts | 2 + .../extension/src/services/storage/index.ts | 1 + packages/extension/src/types/index.ts | 1 + 27 files changed, 294 insertions(+), 50 deletions(-) create mode 100644 packages/extension/src/components/option/HubShareToast.tsx diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 633fc4af..900016fd 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Bewertung schreiben" }, + "hub_share_toast_message": { + "message": "Möchten Sie diesen Befehl mit dem Selection Command Hub teilen?" + }, + "hub_share_toast_button": { + "message": "Teilen" + }, + "hub_share_toast_later": { + "message": "Später" + }, "Option_shortcuts": { "message": "Tastenkombinationen" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 143cc3da..038120f9 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1013,6 +1013,17 @@ "review_request_button": { "message": "Write a Review" }, + "hub_share_toast_message": { + "message": "Would you like to share this command with the Selection Command Hub?", + "description": "Toast message shown once, right after a new command is created, inviting the user to share it to the Hub." + }, + "hub_share_toast_button": { + "message": "Share", + "description": "Button label to share the newly created command to the Hub from the toast." + }, + "hub_share_toast_later": { + "message": "Later" + }, "Option_shortcuts": { "message": "Shortcut Keys" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index f18df98f..c01c810d 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Escribir una Reseña" }, + "hub_share_toast_message": { + "message": "¿Quieres compartir este comando con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartir" + }, + "hub_share_toast_later": { + "message": "Luego" + }, "Option_shortcuts": { "message": "Teclas de Acceso Rápido" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 1513d3f3..732c8fea 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Écrire un Avis" }, + "hub_share_toast_message": { + "message": "Souhaitez-vous partager cette commande avec Selection Command Hub ?" + }, + "hub_share_toast_button": { + "message": "Partager" + }, + "hub_share_toast_later": { + "message": "Plus tard" + }, "Option_shortcuts": { "message": "Raccourcis Clavier" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 9e76ed86..838d19a0 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "समीक्षा लिखें" }, + "hub_share_toast_message": { + "message": "क्या आप इस कमांड को Selection Command Hub पर साझा करना चाहेंगे?" + }, + "hub_share_toast_button": { + "message": "साझा करें" + }, + "hub_share_toast_later": { + "message": "बाद में" + }, "Option_shortcuts": { "message": "शॉर्टकट" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 2d645b1c..cbb2eced 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_message": { + "message": "Ingin membagikan perintah ini ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Bagikan" + }, + "hub_share_toast_later": { + "message": "Nanti" + }, "Option_shortcuts": { "message": "Tombol Pintas" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 7783179f..7173eae0 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Scrivi recensione" }, + "hub_share_toast_message": { + "message": "Vuoi condividere questo comando con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Condividi" + }, + "hub_share_toast_later": { + "message": "Dopo" + }, "Option_shortcuts": { "message": "Scorciatoie" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index b7575757..01017a33 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -999,6 +999,15 @@ "review_request_button": { "message": "レビューする" }, + "hub_share_toast_message": { + "message": "コマンドをSelection Command Hubへ共有しませんか?" + }, + "hub_share_toast_button": { + "message": "共有する" + }, + "hub_share_toast_later": { + "message": "後で" + }, "Option_shortcuts": { "message": "ショートカットキー" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index bd50bf0f..03f40c90 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "리뷰 작성" }, + "hub_share_toast_message": { + "message": "이 명령을 Selection Command Hub에 공유하시겠습니까?" + }, + "hub_share_toast_button": { + "message": "공유하기" + }, + "hub_share_toast_later": { + "message": "나중에" + }, "Option_shortcuts": { "message": "단축키" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index ee087b83..30691955 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_message": { + "message": "Adakah anda ingin berkongsi arahan ini ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Kongsi" + }, + "hub_share_toast_later": { + "message": "Kemudian" + }, "Option_shortcuts": { "message": "Kekunci Pintas" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index d7b024e1..4cdfc572 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_message": { + "message": "Deseja compartilhar este comando com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 142d2cc3..c4f266b9 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_message": { + "message": "Deseja partilhar este comando com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Partilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index a8d06f10..7fe58bdc 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "Написать отзыв" }, + "hub_share_toast_message": { + "message": "Хотите поделиться этой командой в Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Поделиться" + }, + "hub_share_toast_later": { + "message": "Позже" + }, "Option_shortcuts": { "message": "Горячие клавиши" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 8b83224b..d462dff5 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1002,6 +1002,15 @@ "review_request_button": { "message": "写评论" }, + "hub_share_toast_message": { + "message": "要将此命令分享到 Selection Command Hub 吗?" + }, + "hub_share_toast_button": { + "message": "分享" + }, + "hub_share_toast_later": { + "message": "稍后" + }, "Option_shortcuts": { "message": "快捷键" }, diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx new file mode 100644 index 00000000..310541bf --- /dev/null +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -0,0 +1,73 @@ +import { toast } from "sonner" +import { Share } from "lucide-react" +import { t } from "@/services/i18n" +import { cn } from "@/lib/utils" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" +import { shareCommandToHub } from "@/services/hubShare" +import type { SelectionCommand } from "@/types" + +/** + * Shows a one-time toast on the options page suggesting the user share a + * newly created command to the Selection Command Hub. `onShown` is invoked + * regardless of which button is clicked, so callers can persist a + * "don't show again" flag as soon as the toast has been presented. + */ +export function showHubShareToast( + command: SelectionCommand, + onShown: () => void, +): void { + sendEvent( + ANALYTICS_EVENTS.OPEN_DIALOG, + { + event_label: "hub_share_toast", + }, + SCREEN.OPTION, + ) + + toast.custom( + (toastId) => ( +
+
+ + {t("hub_share_toast_message")} +
+
+ + +
+
+ ), + { + duration: 60 * 1000, + }, + ) +} diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index 15da5cab..c894f5d1 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -269,12 +269,14 @@ export function ImportExport() { commandExecutionCount = 0, hasShownReviewRequest = false, hasDismissedPromptHistoryBanner = false, + hasShownHubShareToast = false, } = await enhancedSettings.get() const data = await migrate({ ...importJson, commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars: [], }) await Settings.set(data) diff --git a/packages/extension/src/components/option/Option.tsx b/packages/extension/src/components/option/Option.tsx index 0d18a1c6..940249c2 100644 --- a/packages/extension/src/components/option/Option.tsx +++ b/packages/extension/src/components/option/Option.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react" +import { Toaster } from "sonner" import { Settings } from "@/services/settings/settings" import { capitalize, scrollToSelector } from "@/lib/utils" @@ -48,6 +49,7 @@ export function Option() { return (
+

{APP_ID.split("-").map((n) => { diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 585304d8..5e631920 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -3,26 +3,16 @@ import { Share, CloudCheck } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" -import { shareCommandToHub, getHubLocale } from "@/services/hubShare" -import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" import { - NEW_HUB_SHAREABLE_OPEN_MODES, - COMMAND_SOURCE_TYPE, - NEW_HUB_URL, - HUB_SHARE_EXCLUDED_IDS, - IS_SUPPORT_BUILD, - SCREEN, -} from "@/const" + shareCommandToHub, + getHubLocale, + isHubShareable, +} from "@/services/hubShare" +import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" +import { NEW_HUB_URL, SCREEN } from "@/const" import type { SelectionCommand } from "@/types" import { TEST_IDS } from "@/testIds" -const VALID_SOURCE_TYPES = new Set([ - COMMAND_SOURCE_TYPE.SELF_CREATED, - COMMAND_SOURCE_TYPE.SELF_UPDATED, - COMMAND_SOURCE_TYPE.SELF_REINSTALL, - COMMAND_SOURCE_TYPE.UNKNOWN, -]) - type Props = { command: SelectionCommand onCommandIdChange?: (newId: string) => void @@ -68,14 +58,7 @@ export const ShareButton = ({ } } - if ( - !IS_SUPPORT_BUILD && - (HUB_SHARE_EXCLUDED_IDS.has(command.id) || - !NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) || - !VALID_SOURCE_TYPES.has( - command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, - )) - ) { + if (!isHubShareable(command)) { return null } diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index e0f1125d..e38f3292 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -42,7 +42,8 @@ import { getDescendantFolderIds, } from "@/services/option/commandUtils" import { isValidDrop } from "@/services/option/dragAndDrop" -import { editCommandToHub } from "@/services/hubShare" +import { editCommandToHub, isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" import { Settings } from "@/services/settings/settings" import { useCommandActions } from "@/hooks/option/useCommandActions" import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop" @@ -240,6 +241,16 @@ export const CommandList = ({ control }: CommandListProps) => { }, SCREEN.OPTION, ) + Settings.get().then((settings) => { + if ( + !settings.hasShownHubShareToast && + isHubShareable(data as SelectionCommand) + ) { + showHubShareToast(data as SelectionCommand, () => { + Settings.update("hasShownHubShareToast", () => true) + }) + } + }) } } else { const idx = folderArray.fields.findIndex((f) => f.id === data.id) diff --git a/packages/extension/src/hooks/useSettings.test.tsx b/packages/extension/src/hooks/useSettings.test.tsx index 74b297be..15888080 100644 --- a/packages/extension/src/hooks/useSettings.test.tsx +++ b/packages/extension/src/hooks/useSettings.test.tsx @@ -375,6 +375,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) @@ -694,6 +695,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 7265ba81..ecd59324 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -1,9 +1,23 @@ -import { NEW_HUB_SUPPORTED_LOCALES, type NewHubLocale } from "@/const" +import { + NEW_HUB_SUPPORTED_LOCALES, + NEW_HUB_SHAREABLE_OPEN_MODES, + HUB_SHARE_EXCLUDED_IDS, + COMMAND_SOURCE_TYPE, + IS_SUPPORT_BUILD, + type NewHubLocale, +} from "@/const" import { getAiServicesFallback } from "@/services/aiPromptFallback" import { isAiPromptCommand, isPageActionCommand } from "@/lib/utils" import { Ipc, BgCommand } from "@/services/ipc" import type { SelectionCommand, SearchCommand } from "@/types" +const HUB_SHAREABLE_SOURCE_TYPES = new Set([ + COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.SELF_UPDATED, + COMMAND_SOURCE_TYPE.SELF_REINSTALL, + COMMAND_SOURCE_TYPE.UNKNOWN, +]) + // ---- Type definitions ------------------------------------------------------ export type SubmitCommandInput = { @@ -60,6 +74,24 @@ export function toSubmitCommandInput( } } +// ---- Eligibility check ------------------------------------------------------ + +/** + * Determines whether a command is eligible to be shared to the Hub. + * Support builds bypass this check to make testing the share flow easier. + */ +export function isHubShareable(command: SelectionCommand): boolean { + if (IS_SUPPORT_BUILD) return true + + return ( + !HUB_SHARE_EXCLUDED_IDS.has(command.id) && + NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) && + HUB_SHAREABLE_SOURCE_TYPES.has( + command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, + ) + ) +} + // ---- Share main logic ------------------------------------------------------ export function shareCommandToHub(command: SelectionCommand): boolean { diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 73ad617b..aa50c24f 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -66,6 +66,7 @@ export const emptySettings: SettingsType = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, shortcuts: { shortcuts: [] }, windowOption: { sidePanelAutoHide: false, diff --git a/packages/extension/src/services/settings/enhancedSettings.ts b/packages/extension/src/services/settings/enhancedSettings.ts index 13fc93fb..0a5b1fe2 100644 --- a/packages/extension/src/services/settings/enhancedSettings.ts +++ b/packages/extension/src/services/settings/enhancedSettings.ts @@ -47,26 +47,27 @@ export class EnhancedSettings { : Promise.resolve([]), sections.includes(CACHE_SECTIONS.USER_SETTINGS) ? settingsCache.get( - CACHE_SECTIONS.USER_SETTINGS, - forceFresh, - ) + CACHE_SECTIONS.USER_SETTINGS, + forceFresh, + ) : Promise.resolve(DefaultSettings as UserSettings), sections.includes(CACHE_SECTIONS.STARS) ? settingsCache.get(CACHE_SECTIONS.STARS, forceFresh) : Promise.resolve([]), sections.includes(CACHE_SECTIONS.SHORTCUTS) ? settingsCache.get( - CACHE_SECTIONS.SHORTCUTS, - forceFresh, - ) + CACHE_SECTIONS.SHORTCUTS, + forceFresh, + ) : Promise.resolve({ shortcuts: [] }), sections.includes(CACHE_SECTIONS.USER_STATS) ? settingsCache.get(CACHE_SECTIONS.USER_STATS, forceFresh) : Promise.resolve({ - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - }), + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + }), ]) // Process results @@ -93,10 +94,11 @@ export class EnhancedSettings { userStatsResult.status === "fulfilled" ? userStatsResult.value : { - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - } + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + } // Merge settings const mergedSettings = this.mergeSettings({ @@ -128,16 +130,16 @@ export class EnhancedSettings { forceFresh = false, ): Promise< K extends "commands" - ? Command[] - : K extends "userSettings" - ? UserSettings - : K extends "stars" - ? Star[] - : K extends "shortcuts" - ? ShortcutSettings - : K extends "userStats" - ? UserStats - : any + ? Command[] + : K extends "userSettings" + ? UserSettings + : K extends "stars" + ? Star[] + : K extends "shortcuts" + ? ShortcutSettings + : K extends "userStats" + ? UserStats + : any > { if (section === CACHE_SECTIONS.COMMANDS) { let commands = await settingsCache.get( @@ -182,6 +184,7 @@ export class EnhancedSettings { hasShownReviewRequest: data.userStats.hasShownReviewRequest, hasDismissedPromptHistoryBanner: data.userStats.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: data.userStats.hasShownHubShareToast, } as SettingsType } diff --git a/packages/extension/src/services/settings/settings.test.ts b/packages/extension/src/services/settings/settings.test.ts index b14910e3..39102bd3 100644 --- a/packages/extension/src/services/settings/settings.test.ts +++ b/packages/extension/src/services/settings/settings.test.ts @@ -193,6 +193,7 @@ describe("Settings", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } @@ -213,6 +214,7 @@ describe("Settings", () => { hasShownReviewRequest: mockSettings.hasShownReviewRequest, hasDismissedPromptHistoryBanner: mockSettings.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: mockSettings.hasShownHubShareToast, }) expect(mockStorage.set).toHaveBeenCalledWith( STORAGE_KEY.SHORTCUTS, @@ -632,6 +634,7 @@ describe("migrate function", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index f0ebd066..e70f8d69 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -137,6 +137,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars, ...restData } = data @@ -145,6 +146,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, } ps.push(Storage.set(STORAGE_KEY.USER_STATS, userStats)) diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 0b24e70d..9d27bf20 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -57,6 +57,7 @@ const DEFAULTS = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, }, [STORAGE_KEY.SHORTCUTS]: { shortcuts: [], diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 67c3d92f..fe4586d6 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -180,6 +180,7 @@ export type UserStats = { commandExecutionCount: number hasShownReviewRequest: boolean hasDismissedPromptHistoryBanner: boolean + hasShownHubShareToast: boolean } export type ShortcutCommand = { From adf169ea2af2db183eefedcfa022516a27b56f35 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 15:24:38 +0900 Subject: [PATCH 06/37] =?UTF-8?q?Update:=20Command=20Hub=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=AF=E3=82=92=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E4=B8=80=E8=A6=A7=E3=81=8B=E3=82=89=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E7=A8=AE=E5=88=A5=E9=81=B8=E6=8A=9E=E3=83=80?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=B8=E7=A7=BB=E5=8B=95?= =?UTF-8?q?=E3=81=97=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=A9=E3=82=A4=E3=82=BA?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コマンドリストのボタン群をシンプル化し、Hubへのリンクは新規コマンド作成時の 種別選択ダイアログ右上にバナー形式で配置。リンクラベルは全言語のmessages.jsonに Option_commandType_hubLinkキーを追加して多言語対応した。 --- .../public/SelectionCommandHub_new.png | Bin 0 -> 19373 bytes .../public/_locales/de/messages.json | 3 + .../public/_locales/en/messages.json | 3 + .../public/_locales/es/messages.json | 3 + .../public/_locales/fr/messages.json | 3 + .../public/_locales/hi/messages.json | 3 + .../public/_locales/id/messages.json | 3 + .../public/_locales/it/messages.json | 3 + .../public/_locales/ja/messages.json | 3 + .../public/_locales/ko/messages.json | 3 + .../public/_locales/ms/messages.json | 3 + .../public/_locales/pt_BR/messages.json | 3 + .../public/_locales/pt_PT/messages.json | 3 + .../public/_locales/ru/messages.json | 3 + .../public/_locales/zh_CN/messages.json | 3 + .../option/editor/CommandListMenu.tsx | 95 +++++++----------- .../editor/CommandTypeSelectionDialog.tsx | 58 +++++++++-- 17 files changed, 128 insertions(+), 67 deletions(-) create mode 100644 packages/extension/public/SelectionCommandHub_new.png diff --git a/packages/extension/public/SelectionCommandHub_new.png b/packages/extension/public/SelectionCommandHub_new.png new file mode 100644 index 0000000000000000000000000000000000000000..d6a24eb678b66087f7d6373462e54ca3d18e3655 GIT binary patch literal 19373 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 literal 0 HcmV?d00001 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/components/option/editor/CommandListMenu.tsx b/packages/extension/src/components/option/editor/CommandListMenu.tsx index f9050cac..c78a829c 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 { Plus, 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 = ({
))}
+ + + + +
From 394c467c80185796dc974e11db5ea60f58dffc67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:12:23 +0000 Subject: [PATCH 07/37] perf: use cached user stats for hub share toast Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- .../components/option/editor/CommandList.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index e38f3292..22c2b5a4 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -44,6 +44,8 @@ import { import { isValidDrop } from "@/services/option/dragAndDrop" import { editCommandToHub, isHubShareable } from "@/services/hubShare" import { showHubShareToast } from "@/components/option/HubShareToast" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { useCommandActions } from "@/hooks/option/useCommandActions" import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop" @@ -241,16 +243,18 @@ export const CommandList = ({ control }: CommandListProps) => { }, SCREEN.OPTION, ) - Settings.get().then((settings) => { - if ( - !settings.hasShownHubShareToast && - isHubShareable(data as SelectionCommand) - ) { - showHubShareToast(data as SelectionCommand, () => { - Settings.update("hasShownHubShareToast", () => true) - }) - } - }) + enhancedSettings + .getSection(CACHE_SECTIONS.USER_STATS) + .then((userStats) => { + if ( + !userStats.hasShownHubShareToast && + isHubShareable(data as SelectionCommand) + ) { + showHubShareToast(data as SelectionCommand, () => { + Settings.update("hasShownHubShareToast", () => true) + }) + } + }) } } else { const idx = folderArray.fields.findIndex((f) => f.id === data.id) From 04cae65623f97ea22564dad1e1d8a9caa10d6b90 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:18:56 +0900 Subject: [PATCH 08/37] =?UTF-8?q?Update:=20Hub=E6=9C=AA=E7=99=BB=E9=8C=B2?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE=E3=82=B7=E3=82=A7?= =?UTF-8?q?=E3=82=A2=E6=99=82=E3=81=AB=E3=82=B5=E3=82=A4=E3=83=B3=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E7=94=BB=E9=9D=A2=E3=81=B8=E8=AA=98=E5=B0=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初回シェア時、未登録ユーザーもダッシュボード経由でログイン画面にリダイレクトされ 自分で登録画面を探す必要があった。HUB_REGISTEREDフラグをローカルに永続化し、 未登録の場合はサインアップ画面を直接開くように変更。 Co-Authored-By: Claude Sonnet 5 --- .../src/components/option/ShareButton.tsx | 15 +++++- .../src/services/hub/background.test.ts | 39 +++++++++++++- .../extension/src/services/hub/background.ts | 54 +++++++++++++------ .../extension/src/services/storage/const.ts | 1 + .../extension/src/services/storage/index.ts | 1 + 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 585304d8..6f02cf8d 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -5,6 +5,7 @@ import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" import { shareCommandToHub, getHubLocale } from "@/services/hubShare" import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" +import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import { NEW_HUB_SHAREABLE_OPEN_MODES, COMMAND_SOURCE_TYPE, @@ -37,7 +38,7 @@ export const ShareButton = ({ const buttonRef = useRef(null) const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") - const handleClick = (e: React.MouseEvent) => { + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() if (isShared) { @@ -56,6 +57,18 @@ export const ShareButton = ({ } const ok = shareCommandToHub(commandToShare) + + // 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) { + return + } + setStatus(ok ? "sent" : "error") setTimeout(() => setStatus("idle"), 2000) 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/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]: [], From e248b3dbeee5dc8add8e5920f8afa42483fc34a0 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:20:34 +0900 Subject: [PATCH 09/37] Update: Changes icon for comamnd. --- .../src/components/option/editor/CommandListMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/components/option/editor/CommandListMenu.tsx b/packages/extension/src/components/option/editor/CommandListMenu.tsx index c78a829c..4c6c8faf 100644 --- a/packages/extension/src/components/option/editor/CommandListMenu.tsx +++ b/packages/extension/src/components/option/editor/CommandListMenu.tsx @@ -1,6 +1,6 @@ import React from "react" import { Button } from "@/components/ui/button" -import { Plus, FolderPlus } from "lucide-react" +import { FolderPlus, SquareChevronRight } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { t as _t } from "@/services/i18n" import { TEST_IDS } from "@/testIds" @@ -52,7 +52,7 @@ export const CommandListMenu: React.FC = ({ onClick={onAddCommand} data-testid={TEST_IDS.addCommandButton} > - + {t("Command")} Date: Sat, 8 Aug 2026 16:33:18 +0900 Subject: [PATCH 10/37] Fix: ensure hub share toast onShown fires on auto-close, add tests onAutoClose was unhandled so the "shown once" flag never persisted when the toast timed out, letting it reappear on the next command. Also adds unit tests for isHubShareable, HubShareToast, and the new-command toast trigger branch in CommandList that were previously uncovered. Co-Authored-By: Claude Sonnet 5 --- .../components/option/HubShareToast.test.tsx | 135 ++++++++++++ .../src/components/option/HubShareToast.tsx | 8 +- .../editor/CommandList.hubShareToast.test.tsx | 197 ++++++++++++++++++ .../extension/src/services/hubShare.test.ts | 91 +++++++- 4 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/components/option/HubShareToast.test.tsx create mode 100644 packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx diff --git a/packages/extension/src/components/option/HubShareToast.test.tsx b/packages/extension/src/components/option/HubShareToast.test.tsx new file mode 100644 index 00000000..a0b10c52 --- /dev/null +++ b/packages/extension/src/components/option/HubShareToast.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { toast } from "sonner" +import { showHubShareToast } from "./HubShareToast" +import { sendEvent } from "@/services/analytics" +import { shareCommandToHub } from "@/services/hubShare" +import { OPEN_MODE } from "@/const" +import type { SearchCommand } from "@/types" + +vi.mock("sonner", () => ({ + toast: { + custom: vi.fn(), + dismiss: vi.fn(), + }, +})) + +vi.mock("@/services/i18n", () => ({ + t: vi.fn((key: string) => { + const translations: Record = { + hub_share_toast_message: "Share this command to the Hub?", + hub_share_toast_later: "Later", + hub_share_toast_button: "Share", + } + return translations[key] || key + }), +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_SHARE: "command_share", + }, + sendEvent: vi.fn(), +})) + +vi.mock("@/services/hubShare", () => ({ + shareCommandToHub: vi.fn(), +})) + +const mockToastCustom = vi.mocked(toast.custom) +const mockToastDismiss = vi.mocked(toast.dismiss) +const mockSendEvent = vi.mocked(sendEvent) +const mockShareCommandToHub = vi.mocked(shareCommandToHub) + +const command: SearchCommand = { + id: "cmd-1", + title: "Test Command", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://google.com/search?q=%s", +} + +// Renders the JSX passed to toast.custom() so its buttons can be interacted with. +function renderToastBody(toastId: string | number = "toast-1") { + const [renderFn] = mockToastCustom.mock.calls[0] + return render(<>{renderFn(toastId)}) +} + +describe("showHubShareToast", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("HST-01: sends an open_dialog analytics event", () => { + showHubShareToast(command, vi.fn()) + + expect(mockSendEvent).toHaveBeenCalledWith( + "open_dialog", + { event_label: "hub_share_toast" }, + expect.anything(), + ) + }) + + it("HST-02: calls toast.custom with a 60 second duration", () => { + showHubShareToast(command, vi.fn()) + + expect(mockToastCustom).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ duration: 60 * 1000 }), + ) + }) + + it("HST-03: renders the message and both buttons", () => { + showHubShareToast(command, vi.fn()) + renderToastBody() + + expect( + screen.getByText("Share this command to the Hub?"), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Later" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Share/ })).toBeInTheDocument() + }) + + it("HST-04: clicking 'Later' dismisses the toast and calls onShown without sharing", async () => { + const user = userEvent.setup() + const onShown = vi.fn() + showHubShareToast(command, onShown) + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: "Later" })) + + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + expect(mockShareCommandToHub).not.toHaveBeenCalled() + }) + + it("HST-05: clicking 'Share' shares the command, dismisses the toast, and calls onShown", async () => { + const user = userEvent.setup() + const onShown = vi.fn() + showHubShareToast(command, onShown) + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: /Share/ })) + + expect(mockShareCommandToHub).toHaveBeenCalledWith(command) + expect(mockSendEvent).toHaveBeenCalledWith( + "command_share", + { event_label: "hub-share-toast" }, + expect.anything(), + ) + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + }) + + it("HST-06: calls onShown when the toast auto-closes via timeout", () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + + const [, options] = mockToastCustom.mock.calls[0] + options?.onAutoClose?.({} as never) + + expect(onShown).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx index 310541bf..2a91aad4 100644 --- a/packages/extension/src/components/option/HubShareToast.tsx +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -10,8 +10,9 @@ import type { SelectionCommand } from "@/types" /** * Shows a one-time toast on the options page suggesting the user share a * newly created command to the Selection Command Hub. `onShown` is invoked - * regardless of which button is clicked, so callers can persist a - * "don't show again" flag as soon as the toast has been presented. + * whenever the toast is dismissed — by clicking either button, or via the + * auto-close timeout — so callers can persist a "don't show again" flag as + * soon as the toast has been presented. */ export function showHubShareToast( command: SelectionCommand, @@ -68,6 +69,9 @@ export function showHubShareToast( ), { duration: 60 * 1000, + onAutoClose: () => { + onShown() + }, }, ) } diff --git a/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx new file mode 100644 index 00000000..26b503bc --- /dev/null +++ b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { useForm } from "react-hook-form" +import { CommandList } from "./CommandList" +import { isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { Settings } from "@/services/settings/settings" +import { OPEN_MODE } from "@/const" +import type { SelectionCommand } from "@/types" + +// Renders as a plain button so the test can trigger onAddCommand directly, +// bypassing the real menu UI. +vi.mock("./CommandListMenu", () => ({ + CommandListMenu: (props: any) => ( + + ), +})) + +vi.mock("@/components/option/editor/CommandTypeSelectionDialog", () => ({ + CommandTypeSelectionDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +let nextSubmittedCommand: SelectionCommand | null = null + +vi.mock("@/components/option/editor/CommandEditDialog", () => ({ + CommandEditDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +vi.mock("@/components/option/editor/FolderEditDialog", () => ({ + FolderEditDialog: () => null, +})) + +vi.mock("./CommandTreeRenderer", () => ({ + CommandTreeRenderer: () => null, +})) + +vi.mock("@dnd-kit/core", () => ({ + DndContext: (props: any) => <>{props.children}, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})) + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: (props: any) => <>{props.children}, + sortableKeyboardCoordinates: vi.fn(), + verticalListSortingStrategy: {}, +})) + +vi.mock("@/hooks/option/useCommandActions", () => ({ + useCommandActions: () => ({ commandRemove: vi.fn() }), +})) + +vi.mock("@/hooks/option/useCommandDragDrop", () => ({ + useCommandDragDrop: () => ({ handleDragEnd: vi.fn() }), +})) + +vi.mock("@/hooks/option/useSharedCommandIds", () => ({ + useSharedCommandIds: () => new Set(), +})) + +vi.mock("@/services/hubShare", () => ({ + editCommandToHub: vi.fn(), + isHubShareable: vi.fn(), +})) + +vi.mock("@/components/option/HubShareToast", () => ({ + showHubShareToast: vi.fn(), +})) + +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + update: vi.fn(), + updateCommandId: vi.fn(), + }, +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_ADD: "command_add", + COMMAND_EDIT: "command_edit", + COMMAND_REMOVE: "command_remove", + }, + sendEvent: vi.fn(), +})) + +const mockIsHubShareable = vi.mocked(isHubShareable) +const mockShowHubShareToast = vi.mocked(showHubShareToast) +const mockGetSection = vi.mocked(enhancedSettings.getSection) +const mockSettingsUpdate = vi.mocked(Settings.update) + +function Wrapper() { + const { control } = useForm({ + defaultValues: { commands: [], folders: [] }, + }) + return +} + +const newCommand: SelectionCommand = { + id: "new-cmd-1", + title: "New Command", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", +} as SelectionCommand + +async function addNewCommand(user: ReturnType) { + await user.click(screen.getByText("add-command")) + await user.click(screen.getByText("select-type")) + await user.click(screen.getByText("submit-command")) +} + +describe("CommandList: hub share toast trigger on new command creation", () => { + beforeEach(() => { + vi.clearAllMocks() + nextSubmittedCommand = newCommand + mockGetSection.mockResolvedValue({ hasShownHubShareToast: false } as any) + mockIsHubShareable.mockReturnValue(true) + }) + + it("CL-01: shows the hub share toast when the command is shareable and not yet shown", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalledWith( + newCommand, + expect.any(Function), + ) + }) + }) + + it("CL-02: does not show the toast when hasShownHubShareToast is already true", async () => { + mockGetSection.mockResolvedValue({ hasShownHubShareToast: true } as any) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-03: does not show the toast when the command is not hub-shareable", async () => { + mockIsHubShareable.mockReturnValue(false) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-04: the onShown callback persists the hasShownHubShareToast flag", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalled() + }) + + const onShown = mockShowHubShareToast.mock.calls[0][1] + onShown() + + expect(mockSettingsUpdate).toHaveBeenCalledWith( + "hasShownHubShareToast", + expect.any(Function), + ) + }) +}) diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index d395b585..532900bd 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -3,9 +3,10 @@ import { getHubLocale, toSubmitCommandInput, shareCommandToHub, + isHubShareable, } from "./hubShare" import { Ipc, BgCommand } from "@/services/ipc" -import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE, COMMAND_SOURCE_TYPE } from "@/const" import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" // Mock the IPC module so that shareCommandToHub does not trigger real messaging @@ -292,3 +293,91 @@ describe("shareCommandToHub", () => { }) }) +// ---- isHubShareable --------------------------------------------------------- + +describe("isHubShareable", () => { + it("HS-01: returns false for a command in the exclusion list", () => { + const cmd = makeSearchCmd({ + id: "0", + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-02: returns false for a non-shareable openMode", () => { + const cmd = makeSearchCmd({ + openMode: OPEN_MODE.COPY, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-03: returns false for a DEFAULT sourceType", () => { + const cmd = makeSearchCmd({ sourceType: COMMAND_SOURCE_TYPE.DEFAULT }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-04: returns false for a HUB_COMMUNITY sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.HUB_COMMUNITY, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-05: returns true for a SELF_CREATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-06: returns true for a SELF_UPDATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_UPDATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-07: returns true for a SELF_REINSTALL sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_REINSTALL, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-08: treats a missing sourceType as UNKNOWN and returns true", () => { + const cmd = makeSearchCmd({ sourceType: undefined }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-09: returns true when all eligibility conditions are met", () => { + const cmd = makeSearchCmd({ + id: "not-excluded", + openMode: OPEN_MODE.TAB, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) +}) + +describe("isHubShareable (support build)", () => { + beforeEach(() => { + vi.resetModules() + vi.stubEnv("VITE_SUPPORT_BUILD", "true") + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("HS-10: bypasses all eligibility checks when IS_SUPPORT_BUILD is true", async () => { + const { isHubShareable: isHubShareableSupportBuild } = + await import("./hubShare") + const cmd = makeSearchCmd({ + id: "0", // normally excluded + openMode: OPEN_MODE.COPY, // normally non-shareable + sourceType: COMMAND_SOURCE_TYPE.DEFAULT, // normally non-shareable + }) + expect(isHubShareableSupportBuild(cmd)).toBe(true) + }) +}) From fd10a2446900d299f78bf789b433d9e6c964b530 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:39:18 +0900 Subject: [PATCH 11/37] Update: Fix review comments. --- .../src/components/option/ShareButton.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 6f02cf8d..0ed18953 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -36,7 +36,9 @@ export const ShareButton = ({ isShared, }: Props) => { const buttonRef = useRef(null) - const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + const [status, setStatus] = useState<"idle" | "pending" | "sent" | "error">( + "idle", + ) const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() @@ -49,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() @@ -58,6 +64,12 @@ export const ShareButton = ({ const ok = shareCommandToHub(commandToShare) + 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 @@ -66,19 +78,18 @@ export const ShareButton = ({ LOCAL_STORAGE_KEY.HUB_REGISTERED, ) if (!registered) { + setStatus("idle") return } - setStatus(ok ? "sent" : "error") + setStatus("sent") setTimeout(() => setStatus("idle"), 2000) - if (ok) { - sendEvent( - ANALYTICS_EVENTS.COMMAND_SHARE, - { event_label: "share-button" }, - SCREEN.OPTION, - ) - } + sendEvent( + ANALYTICS_EVENTS.COMMAND_SHARE, + { event_label: "share-button" }, + SCREEN.OPTION, + ) } if ( From cdbbc21d261cb6987769c73a5531f4e6c98b3b60 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:52:26 +0900 Subject: [PATCH 12/37] Update: Use Terminal icon for the command. --- .../src/components/option/editor/CommandListMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/components/option/editor/CommandListMenu.tsx b/packages/extension/src/components/option/editor/CommandListMenu.tsx index 4c6c8faf..26ab24bd 100644 --- a/packages/extension/src/components/option/editor/CommandListMenu.tsx +++ b/packages/extension/src/components/option/editor/CommandListMenu.tsx @@ -1,6 +1,6 @@ import React from "react" import { Button } from "@/components/ui/button" -import { FolderPlus, SquareChevronRight } from "lucide-react" +import { Terminal, FolderPlus } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { t as _t } from "@/services/i18n" import { TEST_IDS } from "@/testIds" @@ -52,7 +52,7 @@ export const CommandListMenu: React.FC = ({ onClick={onAddCommand} data-testid={TEST_IDS.addCommandButton} > - + {t("Command")} Date: Sat, 8 Aug 2026 17:12:50 +0900 Subject: [PATCH 13/37] Update: Adjust hub share toast UI design. Co-Authored-By: Claude Sonnet 5 --- .../src/components/option/HubShareToast.tsx | 21 ++++++++++++------- .../src/components/option/ShareButton.tsx | 4 +--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx index 2a91aad4..7992bd87 100644 --- a/packages/extension/src/components/option/HubShareToast.tsx +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -28,14 +28,19 @@ export function showHubShareToast( toast.custom( (toastId) => ( -
-
- - {t("hub_share_toast_message")} +
+
+ Selection Command Hub + {t("hub_share_toast_message")}
diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 7b7d4456..e877f0f0 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -42,9 +42,7 @@ export const ShareButton = ({ } // Disable the button immediately to prevent duplicate shares/signup - // tabs from rapid repeated clicks while the - - lookup below runs. + // tabs from rapid repeated clicks while the lookup below runs. setStatus("pending") let commandToShare = command From 22dfa664097ce0f5375a66f4cb0615c550c2ed0e Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 17:18:13 +0900 Subject: [PATCH 14/37] Update: Delay hub share toast to avoid overlapping the dialog close animation. Co-Authored-By: Claude Sonnet 5 --- .../components/option/HubShareToast.test.tsx | 27 +++++++++++++++---- .../src/components/option/HubShareToast.tsx | 12 +++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/components/option/HubShareToast.test.tsx b/packages/extension/src/components/option/HubShareToast.test.tsx index a0b10c52..bedbf4d0 100644 --- a/packages/extension/src/components/option/HubShareToast.test.tsx +++ b/packages/extension/src/components/option/HubShareToast.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import userEvent from "@testing-library/user-event" import { toast } from "sonner" import { showHubShareToast } from "./HubShareToast" @@ -60,10 +60,18 @@ function renderToastBody(toastId: string | number = "toast-1") { describe("showHubShareToast", () => { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers() }) - it("HST-01: sends an open_dialog analytics event", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("HST-01: sends an open_dialog analytics event after the show delay", () => { showHubShareToast(command, vi.fn()) + expect(mockSendEvent).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) expect(mockSendEvent).toHaveBeenCalledWith( "open_dialog", @@ -72,8 +80,11 @@ describe("showHubShareToast", () => { ) }) - it("HST-02: calls toast.custom with a 60 second duration", () => { + it("HST-02: calls toast.custom with a 60 second duration after the show delay", () => { showHubShareToast(command, vi.fn()) + expect(mockToastCustom).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) expect(mockToastCustom).toHaveBeenCalledWith( expect.any(Function), @@ -83,6 +94,7 @@ describe("showHubShareToast", () => { it("HST-03: renders the message and both buttons", () => { showHubShareToast(command, vi.fn()) + vi.advanceTimersByTime(2000) renderToastBody() expect( @@ -93,9 +105,11 @@ describe("showHubShareToast", () => { }) it("HST-04: clicking 'Later' dismisses the toast and calls onShown without sharing", async () => { - const user = userEvent.setup() const onShown = vi.fn() showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() renderToastBody("toast-1") await user.click(screen.getByRole("button", { name: "Later" })) @@ -106,9 +120,11 @@ describe("showHubShareToast", () => { }) it("HST-05: clicking 'Share' shares the command, dismisses the toast, and calls onShown", async () => { - const user = userEvent.setup() const onShown = vi.fn() showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() renderToastBody("toast-1") await user.click(screen.getByRole("button", { name: /Share/ })) @@ -126,6 +142,7 @@ describe("showHubShareToast", () => { it("HST-06: calls onShown when the toast auto-closes via timeout", () => { const onShown = vi.fn() showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) const [, options] = mockToastCustom.mock.calls[0] options?.onAutoClose?.({} as never) diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx index 7992bd87..8123a5f3 100644 --- a/packages/extension/src/components/option/HubShareToast.tsx +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -7,6 +7,9 @@ import { SCREEN } from "@/const" import { shareCommandToHub } from "@/services/hubShare" import type { SelectionCommand } from "@/types" +// Delay before showing the toast so it doesn't overlap the dialog's close animation. +const SHOW_DELAY_MS = 2000 + /** * Shows a one-time toast on the options page suggesting the user share a * newly created command to the Selection Command Hub. `onShown` is invoked @@ -17,6 +20,15 @@ import type { SelectionCommand } from "@/types" export function showHubShareToast( command: SelectionCommand, onShown: () => void, +): void { + setTimeout(() => { + showHubShareToastNow(command, onShown) + }, SHOW_DELAY_MS) +} + +function showHubShareToastNow( + command: SelectionCommand, + onShown: () => void, ): void { sendEvent( ANALYTICS_EVENTS.OPEN_DIALOG, From a94e50a0b6fabc291638174d757717b109fb6577 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 17:29:52 +0900 Subject: [PATCH 15/37] Fix: Handle promise rejection when loading user stats for hub share toast. Co-Authored-By: Claude Sonnet 5 --- .../extension/src/components/option/editor/CommandList.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index 22c2b5a4..5dd603d7 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -255,6 +255,12 @@ export const CommandList = ({ control }: CommandListProps) => { }) } }) + .catch((err) => { + console.error( + "[CommandList] Failed to load user stats for hub share toast:", + err, + ) + }) } } else { const idx = folderArray.fields.findIndex((f) => f.id === data.id) From 4f1e99525ffc7256e773c7e2766443ef7b052e3f Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 18:00:21 +0900 Subject: [PATCH 16/37] v1.1.1 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UaERoKexgDnvXJiXA3LGEB --- package.json | 2 +- packages/extension/manifest.json | 39 ++++++++------------------------ packages/extension/package.json | 2 +- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index aa40747b..5e7edbe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selection-command-monorepo", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "Selection Command - Monorepo for Chrome Extension and Hub", "author": "ujiro99", diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 000b2c96..47fb9869 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -2,20 +2,15 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.1.0", + "version": "1.1.1", "default_locale": "en", "icons": { "128": "icon128.png" }, "content_scripts": [ { - "matches": [ - "http://*/*", - "https://*/*" - ], - "js": [ - "src/content_script.tsx" - ] + "matches": ["http://*/*", "https://*/*"], + "js": ["src/content_script.tsx"] }, { "matches": [ @@ -23,20 +18,14 @@ "https://selection-command-hub.siro-cola.workers.dev/*", "http://localhost:3000/*" ], - "js": [ - "src/new_command_hub.tsx" - ] + "js": ["src/new_command_hub.tsx"] }, { - "matches": [ - "https://*.siro-cola.workers.dev/*" - ], + "matches": ["https://*.siro-cola.workers.dev/*"], "include_globs": [ "https://*-selection-command-hub.siro-cola.workers.dev/*" ], - "js": [ - "src/new_command_hub.tsx" - ] + "js": ["src/new_command_hub.tsx"] } ], "background": { @@ -60,21 +49,11 @@ "system.display", "sidePanel" ], - "host_permissions": [ - "http://*/*", - "https://*/*" - ], + "host_permissions": ["http://*/*", "https://*/*"], "web_accessible_resources": [ { - "matches": [ - "http://*/*", - "https://*/*" - ], - "resources": [ - "assets/*.css", - "images/*", - "icon128.png" - ] + "matches": ["http://*/*", "https://*/*"], + "resources": ["assets/*.css", "images/*", "icon128.png"] } ], "commands": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 3da12c78..08c5b87c 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@selection-command/extension", - "version": "1.1.0", + "version": "1.1.1", "private": true, "author": "ujiro99", "license": "MIT", From be4bea282eae54b248b0952516992c68c4e365bc Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 13 Aug 2026 12:52:29 +0900 Subject: [PATCH 17/37] =?UTF-8?q?Add:=20GA4=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=81=A6=E3=82=A2?= =?UTF-8?q?=E3=83=B3=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=A8=E7=B6=99=E7=B6=9A?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE=E5=88=A9=E7=94=A8?= =?UTF-8?q?=E7=8A=B6=E6=B3=81=E3=82=92=E6=AF=94=E8=BC=83=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installed / option_screen_opened / hub_screen_opened / folder_create / page_rule_create を新設し、command_add・selection_command はコマンド種別 (search/aiprompt/other)で分岐した新イベントに置き換えた。 Co-Authored-By: Claude Sonnet 5 --- .../extension/src/action/executor.test.ts | 4 +- packages/extension/src/action/executor.ts | 6 +- packages/extension/src/background_script.ts | 1 + .../src/components/option/Option.tsx | 14 +++- .../editor/CommandList.hubShareToast.test.tsx | 6 +- .../components/option/editor/CommandList.tsx | 16 +++- .../components/option/editor/PageRuleList.tsx | 4 +- packages/extension/src/const.ts | 12 +++ packages/extension/src/new_command_hub.tsx | 6 ++ packages/extension/src/services/analytics.ts | 74 +++++++++++++++++-- .../src/services/hub/background.test.ts | 5 +- .../extension/src/services/hub/background.ts | 4 +- 12 files changed, 132 insertions(+), 20 deletions(-) diff --git a/packages/extension/src/action/executor.test.ts b/packages/extension/src/action/executor.test.ts index faf04849..99564fe7 100644 --- a/packages/extension/src/action/executor.test.ts +++ b/packages/extension/src/action/executor.test.ts @@ -35,7 +35,7 @@ describe("executeAction", () => { }) expect(sendEvent).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SELECTION_COMMAND, + ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, expect.objectContaining({ command_id: "cmd-1", source_type: COMMAND_SOURCE_TYPE.HUB_COMMUNITY, @@ -61,7 +61,7 @@ describe("executeAction", () => { }) expect(sendEvent).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SELECTION_COMMAND, + ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, expect.objectContaining({ command_id: "cmd-2", source_type: COMMAND_SOURCE_TYPE.UNKNOWN, diff --git a/packages/extension/src/action/executor.ts b/packages/extension/src/action/executor.ts index 52fcddac..321308cf 100644 --- a/packages/extension/src/action/executor.ts +++ b/packages/extension/src/action/executor.ts @@ -1,6 +1,6 @@ import type { ExecuteCommandParams } from "@/types" -import { OPEN_MODE } from "@/const" -import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" +import { OPEN_MODE, getCommandAnalyticsCategory } from "@/const" +import { sendEvent, getSelectionCommandEvent } from "@/services/analytics" import { resolveCommandSource } from "@/services/commandSource" export async function executeAction({ @@ -36,7 +36,7 @@ export async function executeAction({ const { sourceType, sourceId } = resolveCommandSource(command) - sendEvent(ANALYTICS_EVENTS.SELECTION_COMMAND, { + sendEvent(getSelectionCommandEvent(getCommandAnalyticsCategory(mode)), { event_label: mode, command_id: command.id, source_type: sourceType, diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 04311103..c0340721 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -440,6 +440,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { // Initialize default settings on install if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { await Settings.reset() + sendEvent(ANALYTICS_EVENTS.INSTALLED, {}, SCREEN.SERVICE_WORKER) } await ContextMenu.init() diff --git a/packages/extension/src/components/option/Option.tsx b/packages/extension/src/components/option/Option.tsx index 940249c2..87b3d1f0 100644 --- a/packages/extension/src/components/option/Option.tsx +++ b/packages/extension/src/components/option/Option.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { Toaster } from "sonner" import { Settings } from "@/services/settings/settings" import { capitalize, scrollToSelector } from "@/lib/utils" -import { APP_ID, VERSION } from "@/const" +import { APP_ID, VERSION, SCREEN } from "@/const" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" import { Popup } from "@/components/Popup" import { TableOfContents } from "@/components/option/TableOfContents" @@ -21,6 +22,7 @@ export function Option() { const [previewElm, setPreviewElm] = useState(null) const [popupElm, setPopupElm] = useState(null) const [popupHeight, setPopupHeight] = useState(0) + const hasSentScreenOpenedRef = useRef(false) useEffect(() => { const updateHeight = () => { @@ -43,6 +45,14 @@ export function Option() { }, 50) }, []) + useEffect(() => { + // Guard against StrictMode's dev-only double effect invocation, which + // would otherwise send this event twice per page load. + if (hasSentScreenOpenedRef.current) return + hasSentScreenOpenedRef.current = true + sendEvent(ANALYTICS_EVENTS.OPTION_SCREEN_OPENED, {}, SCREEN.OPTION) + }, []) + const onClickMenu = (hash: string) => { scrollToSelector(hash) } diff --git a/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx index 26b503bc..a304797d 100644 --- a/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx +++ b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx @@ -96,11 +96,15 @@ vi.mock("@/services/settings/settings", () => ({ vi.mock("@/services/analytics", () => ({ ANALYTICS_EVENTS: { OPEN_DIALOG: "open_dialog", - COMMAND_ADD: "command_add", + COMMAND_CREATE_SEARCH: "command_create_search", + COMMAND_CREATE_AIPROMPT: "command_create_aiprompt", + COMMAND_CREATE_OTHER: "command_create_other", COMMAND_EDIT: "command_edit", COMMAND_REMOVE: "command_remove", + FOLDER_CREATE: "folder_create", }, sendEvent: vi.fn(), + getCommandCreateEvent: vi.fn(() => "command_create_other"), })) const mockIsHubShareable = vi.mocked(isHubShareable) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index 5dd603d7..e9f255db 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -26,8 +26,17 @@ import { FoldersSchemaType, } from "@/types/schema" -import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" -import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" +import { + ANALYTICS_EVENTS, + sendEvent, + getCommandCreateEvent, +} from "@/services/analytics" +import { + SCREEN, + COMMAND_TYPE, + OPEN_MODE_TYPE_MAP, + getCommandAnalyticsCategory, +} from "@/const" import type { Command, CommandFolder, SelectionCommand } from "@/types" // Imported services and hooks @@ -237,7 +246,7 @@ export const CommandList = ({ control }: CommandListProps) => { } else { commandArray.append(data as CommandSchemaType) sendEvent( - ANALYTICS_EVENTS.COMMAND_ADD, + getCommandCreateEvent(getCommandAnalyticsCategory(data.openMode)), { event_label: data.openMode, }, @@ -268,6 +277,7 @@ export const CommandList = ({ control }: CommandListProps) => { folderArray.update(idx, data) } else { folderArray.append(data) + sendEvent(ANALYTICS_EVENTS.FOLDER_CREATE, {}, SCREEN.OPTION) } } } diff --git a/packages/extension/src/components/option/editor/PageRuleList.tsx b/packages/extension/src/components/option/editor/PageRuleList.tsx index 34238cda..225a6041 100644 --- a/packages/extension/src/components/option/editor/PageRuleList.tsx +++ b/packages/extension/src/components/option/editor/PageRuleList.tsx @@ -43,7 +43,8 @@ import { PopupPlacementField } from "@/components/option/field/PopupPlacementFie import { PopupPlacement } from "@/services/option/defaultSettings" import { t as _t } from "@/services/i18n" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) -import { POPUP_ENABLED, LINK_COMMAND_ENABLED, INHERIT } from "@/const" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { POPUP_ENABLED, LINK_COMMAND_ENABLED, INHERIT, SCREEN } from "@/const" import { e2a, cn, scrollToSelector } from "@/lib/utils" import type { PageRule, PopupPlacementOrInherit } from "@/types" import { popupPlacementSchema } from "@/types/schema" @@ -234,6 +235,7 @@ export const PageRuleList = ({ ) if (index === -1) { pageRuleArray.append({ ...rule, createdAt: Date.now() }) + sendEvent(ANALYTICS_EVENTS.PAGE_RULE_CREATE, {}, SCREEN.OPTION) } else { const existingCreatedAt = pageRuleArray.fields[index].createdAt pageRuleArray.update(index, { ...rule, createdAt: existingCreatedAt }) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index b4c4ba8a..2f0eb9c4 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -42,6 +42,18 @@ export const OPEN_MODE_TYPE_MAP = { [OPEN_MODE.ADD_PAGE_RULE]: COMMAND_TYPE.OPTION, } as const +// Coarse-grained category used to group per-command-type analytics events. +export type CommandAnalyticsCategory = "search" | "aiprompt" | "other" + +export function getCommandAnalyticsCategory( + openMode: OPEN_MODE, +): CommandAnalyticsCategory { + const type = OPEN_MODE_TYPE_MAP[openMode] + if (type === COMMAND_TYPE.SEARCH) return "search" + if (type === COMMAND_TYPE.AI_PROMPT) return "aiprompt" + return "other" +} + // Reverse mapping: COMMAND_TYPE -> OPEN_MODE[] export const COMMAND_TYPE_OPEN_MODES_MAP = { [COMMAND_TYPE.SEARCH]: [ diff --git a/packages/extension/src/new_command_hub.tsx b/packages/extension/src/new_command_hub.tsx index 6bad11f7..d6c9553c 100644 --- a/packages/extension/src/new_command_hub.tsx +++ b/packages/extension/src/new_command_hub.tsx @@ -1,9 +1,15 @@ +import { useEffect } from "react" import { createRoot } from "react-dom/client" import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" /** Minimal React component that activates the hub bridge hook. */ function NewCommandHubBridge(): JSX.Element { useCommandHubBridge() + useEffect(() => { + sendEvent(ANALYTICS_EVENTS.HUB_SCREEN_OPENED, {}, SCREEN.COMMAND_HUB) + }, []) return <> } diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index 65b562de..d7b8d71c 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -1,5 +1,12 @@ import { Storage, LOCAL_STORAGE_KEY, SESSION_STORAGE_KEY } from "./storage" -import { isDebug, isE2E, APP_ID, VERSION, SCREEN } from "@/const" +import { + isDebug, + isE2E, + APP_ID, + VERSION, + SCREEN, + type CommandAnalyticsCategory, +} from "@/const" import { SessionData } from "@/types" const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect" @@ -15,24 +22,77 @@ const IS_CI = const DISABLE_ANALYTICS = IS_CI || isE2E export const ANALYTICS_EVENTS = { - SELECTION_COMMAND: "selection_command", + INSTALLED: "installed", + OPTION_SCREEN_OPENED: "option_screen_opened", + HUB_SCREEN_OPENED: "hub_screen_opened", + COMMAND_CREATE_SEARCH: "command_create_search", + COMMAND_CREATE_AIPROMPT: "command_create_aiprompt", + COMMAND_CREATE_OTHER: "command_create_other", + HUB_ADD_SEARCH: "hub_add_search", + HUB_ADD_AIPROMPT: "hub_add_aiprompt", + HUB_ADD_OTHER: "hub_add_other", + SELECTION_COMMAND_SEARCH: "selection_command_search", + SELECTION_COMMAND_AIPROMPT: "selection_command_aiprompt", + SELECTION_COMMAND_OTHER: "selection_command_other", LINK_COMMAND: "link_command", + FOLDER_CREATE: "folder_create", + PAGE_RULE_CREATE: "page_rule_create", SHORTCUT: "shortcut", SHOW_HELP: "show_help", SHOW_REVIEW_URL: "show_review_url", OPEN_DIALOG: "open_dialog", - COMMAND_ADD: "command_add", COMMAND_EDIT: "command_edit", COMMAND_REMOVE: "command_remove", COMMAND_SHARE: "command_share", - COMMAND_HUB_ADD: "command_hub_add", - COMMAND_HUB_STAR_ADD: "command_hub_star_add", - COMMAND_HUB_STAR_REMOVE: "command_hub_star_remove", } as const export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] +// Per-command-type analytics events, keyed by the coarse category derived +// from getCommandAnalyticsCategory(). +const COMMAND_CREATE_EVENTS: Record< + CommandAnalyticsCategory, + AnalyticsEventName +> = { + search: ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH, + aiprompt: ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT, + other: ANALYTICS_EVENTS.COMMAND_CREATE_OTHER, +} + +const HUB_ADD_EVENTS: Record = { + search: ANALYTICS_EVENTS.HUB_ADD_SEARCH, + aiprompt: ANALYTICS_EVENTS.HUB_ADD_AIPROMPT, + other: ANALYTICS_EVENTS.HUB_ADD_OTHER, +} + +const SELECTION_COMMAND_EVENTS: Record< + CommandAnalyticsCategory, + AnalyticsEventName +> = { + search: ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, + aiprompt: ANALYTICS_EVENTS.SELECTION_COMMAND_AIPROMPT, + other: ANALYTICS_EVENTS.SELECTION_COMMAND_OTHER, +} + +export function getCommandCreateEvent( + category: CommandAnalyticsCategory, +): AnalyticsEventName { + return COMMAND_CREATE_EVENTS[category] +} + +export function getHubAddEvent( + category: CommandAnalyticsCategory, +): AnalyticsEventName { + return HUB_ADD_EVENTS[category] +} + +export function getSelectionCommandEvent( + category: CommandAnalyticsCategory, +): AnalyticsEventName { + return SELECTION_COMMAND_EVENTS[category] +} + // https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 export async function sendEvent( @@ -40,6 +100,8 @@ export async function sendEvent( params: any, screen = SCREEN.CONTENT_SCRIPT, ) { + console.debug(`[analytics] sendEvent: ${name}`, params, screen) + // Do not send analytics data if running in CI or e2e build. if (DISABLE_ANALYTICS || !MEASUREMENT_ID || !API_SECRET) { return diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index f58c8d36..d4014e6b 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -37,11 +37,14 @@ vi.mock("@/services/settings/settings", () => ({ vi.mock("@/services/analytics", () => ({ ANALYTICS_EVENTS: { - COMMAND_ADD: "command_add", + HUB_ADD_SEARCH: "hub_add_search", + HUB_ADD_AIPROMPT: "hub_add_aiprompt", + HUB_ADD_OTHER: "hub_add_other", COMMAND_REMOVE: "command_remove", }, sendEvent: vi.fn(), getOrCreateClientId: vi.fn(), + getHubAddEvent: vi.fn(() => "hub_add_other"), })) vi.mock("@/const", async (importOriginal) => { diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 78293b51..f53ed40e 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -5,6 +5,7 @@ import { OPTION_PAGE_PATH, COMMAND_SOURCE_TYPE, SCREEN, + getCommandAnalyticsCategory, } from "@/const" import type { Sender } from "@/services/ipc" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" @@ -15,6 +16,7 @@ import { ANALYTICS_EVENTS, sendEvent, getOrCreateClientId, + getHubAddEvent, } from "@/services/analytics" import { PopupOption } from "@/services/option/defaultSettings" import { @@ -366,7 +368,7 @@ export async function handleAddCommand( await Settings.addCommands([cmd]) console.debug("[handleAddCommand] Saved command id:", cmd.id) await sendEvent( - ANALYTICS_EVENTS.COMMAND_ADD, + getHubAddEvent(getCommandAnalyticsCategory(cmd.openMode)), { event_label: cmd.openMode, source_type: sourceInfo.sourceType, From f8bf79b32f15b8548a59860496324a0c4e7510a3 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 13 Aug 2026 13:37:30 +0900 Subject: [PATCH 18/37] =?UTF-8?q?Add:=20=E6=96=B0=E8=A6=8FGA4=E3=82=A4?= =?UTF-8?q?=E3=83=99=E3=83=B3=E3=83=88=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=AB=E3=83=90=E3=83=AC=E3=83=83=E3=82=B8=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installed / option_screen_opened / hub_screen_opened / folder_create / page_rule_create の分岐ロジックと command_create_*・hub_add_*・ selection_command_* のカテゴリ振り分けを検証するテストを追加。 new_command_hub.tsx はテスト容易性のため NewCommandHubBridge を コンポーネントとして切り出した。 Co-Authored-By: Claude Sonnet 5 --- .../extension/src/background_script.test.ts | 53 +++++ .../components/NewCommandHubBridge.test.tsx | 37 ++++ .../src/components/NewCommandHubBridge.tsx | 13 ++ .../src/components/option/Option.test.tsx | 64 ++++++ .../editor/CommandList.analytics.test.tsx | 198 ++++++++++++++++++ .../option/editor/PageRuleList.test.tsx | 82 ++++++++ .../components/option/editor/PageRuleList.tsx | 15 +- packages/extension/src/const.test.ts | 22 ++ packages/extension/src/new_command_hub.tsx | 14 +- .../extension/src/services/analytics.test.ts | 37 ++++ packages/extension/src/test/setup.ts | 18 +- packages/extension/src/testIds.ts | 4 + 12 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 packages/extension/src/components/NewCommandHubBridge.test.tsx create mode 100644 packages/extension/src/components/NewCommandHubBridge.tsx create mode 100644 packages/extension/src/components/option/Option.test.tsx create mode 100644 packages/extension/src/components/option/editor/CommandList.analytics.test.tsx create mode 100644 packages/extension/src/components/option/editor/PageRuleList.test.tsx create mode 100644 packages/extension/src/const.test.ts create mode 100644 packages/extension/src/services/analytics.test.ts diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 587632e5..0380da0c 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -567,3 +567,56 @@ describe("Popup Auto-Close Delay", () => { expect(mockRemoveWindow).not.toHaveBeenCalled() }) }) + +describe("onInstalled: installed analytics event", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("IN-01: sends the installed event when reason is install", async () => { + const mockSendEvent = vi.fn() + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: mockSendEvent, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + expect(listenerCalls.length).toBeGreaterThan(0) + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + + expect(mockSendEvent).toHaveBeenCalledWith("installed", {}, "ServiceWorker") + }) + + it("IN-02: does not send the installed event when reason is update", async () => { + const mockSendEvent = vi.fn() + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: mockSendEvent, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.UPDATE, + }) + + expect(mockSendEvent).not.toHaveBeenCalledWith( + "installed", + expect.anything(), + expect.anything(), + ) + }) +}) diff --git a/packages/extension/src/components/NewCommandHubBridge.test.tsx b/packages/extension/src/components/NewCommandHubBridge.test.tsx new file mode 100644 index 00000000..368f6d7b --- /dev/null +++ b/packages/extension/src/components/NewCommandHubBridge.test.tsx @@ -0,0 +1,37 @@ +import { render } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { NewCommandHubBridge } from "./NewCommandHubBridge" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" +import { sendEvent } from "@/services/analytics" + +vi.mock("@/hooks/useCommandHubBridge", () => ({ + useCommandHubBridge: vi.fn(), +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + HUB_SCREEN_OPENED: "hub_screen_opened", + }, + sendEvent: vi.fn(), +})) + +const mockUseCommandHubBridge = vi.mocked(useCommandHubBridge) +const mockSendEvent = vi.mocked(sendEvent) + +describe("NewCommandHubBridge: hub_screen_opened analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("NCH-01: activates the hub bridge hook and sends the event once", () => { + render() + + expect(mockUseCommandHubBridge).toHaveBeenCalled() + expect(mockSendEvent).toHaveBeenCalledTimes(1) + expect(mockSendEvent).toHaveBeenCalledWith( + "hub_screen_opened", + {}, + "CommandHub", + ) + }) +}) diff --git a/packages/extension/src/components/NewCommandHubBridge.tsx b/packages/extension/src/components/NewCommandHubBridge.tsx new file mode 100644 index 00000000..332cc9e3 --- /dev/null +++ b/packages/extension/src/components/NewCommandHubBridge.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" + +/** Minimal React component that activates the hub bridge hook. */ +export function NewCommandHubBridge(): JSX.Element { + useCommandHubBridge() + useEffect(() => { + sendEvent(ANALYTICS_EVENTS.HUB_SCREEN_OPENED, {}, SCREEN.COMMAND_HUB) + }, []) + return <> +} diff --git a/packages/extension/src/components/option/Option.test.tsx b/packages/extension/src/components/option/Option.test.tsx new file mode 100644 index 00000000..52134185 --- /dev/null +++ b/packages/extension/src/components/option/Option.test.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { render } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Option } from "./Option" +import { sendEvent } from "@/services/analytics" + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + addChangedListener: vi.fn(), + removeChangedListener: vi.fn(), + }, +})) + +vi.mock("@/components/Popup", () => ({ Popup: () => null })) +vi.mock("@/components/option/TableOfContents", () => ({ + TableOfContents: () => null, +})) +vi.mock("@/components/option/ImportExport", () => ({ + ImportExport: () => null, +})) +vi.mock("@/components/option/UserSupport", () => ({ UserSupport: () => null })) +vi.mock("@/components/option/DeveloperSupport", () => ({ + DeveloperSupport: () => null, +})) +vi.mock("@/components/option/HubBanner", () => ({ HubBanner: () => null })) +vi.mock("@/components/option/HubUserInfo", () => ({ HubUserInfo: () => null })) +vi.mock("@/components/option/SettingForm", () => ({ SettingForm: () => null })) +vi.mock("@/components/option/StorageUsage", () => ({ default: () => null })) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPTION_SCREEN_OPENED: "option_screen_opened", + }, + sendEvent: vi.fn(), +})) + +const mockSendEvent = vi.mocked(sendEvent) + +describe("Option: option_screen_opened analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("OPT-01: sends the event exactly once on mount", () => { + render(
{ editorRef.current = field setDialogOpen(true) @@ -415,6 +418,12 @@ export const PageRuleDialog = ({ const popupPlacement = watch("popupPlacement") const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(false) + const urlPatternInputProps = { + type: "string", + "data-testid": TEST_IDS.pageRuleUrlPatternInput, + ...register("urlPattern", {}), + } + const handlePopupPlacementSubmit = ( data: z.infer, ) => { @@ -459,10 +468,7 @@ export const PageRuleDialog = ({ control={form.control} name="urlPattern" formLabel={t("urlPattern")} - inputProps={{ - type: "string", - ...register("urlPattern", {}), - }} + inputProps={urlPatternInputProps} /> { onSubmit(data as PageRule) onOpenChange(false) diff --git a/packages/extension/src/const.test.ts b/packages/extension/src/const.test.ts new file mode 100644 index 00000000..81791caa --- /dev/null +++ b/packages/extension/src/const.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest" +import { OPEN_MODE, getCommandAnalyticsCategory } from "@/const" + +describe("getCommandAnalyticsCategory", () => { + it.each([ + [OPEN_MODE.POPUP, "search"], + [OPEN_MODE.WINDOW, "search"], + [OPEN_MODE.TAB, "search"], + [OPEN_MODE.BACKGROUND_TAB, "search"], + [OPEN_MODE.SIDE_PANEL, "search"], + [OPEN_MODE.AI_PROMPT, "aiprompt"], + [OPEN_MODE.API, "other"], + [OPEN_MODE.PAGE_ACTION, "other"], + [OPEN_MODE.LINK_POPUP, "other"], + [OPEN_MODE.COPY, "other"], + [OPEN_MODE.GET_TEXT_STYLES, "other"], + [OPEN_MODE.OPTION, "other"], + [OPEN_MODE.ADD_PAGE_RULE, "other"], + ] as const)("maps %s to category %s", (openMode, expected) => { + expect(getCommandAnalyticsCategory(openMode)).toBe(expected) + }) +}) diff --git a/packages/extension/src/new_command_hub.tsx b/packages/extension/src/new_command_hub.tsx index d6c9553c..8dc9b179 100644 --- a/packages/extension/src/new_command_hub.tsx +++ b/packages/extension/src/new_command_hub.tsx @@ -1,17 +1,5 @@ -import { useEffect } from "react" import { createRoot } from "react-dom/client" -import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" -import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" -import { SCREEN } from "@/const" - -/** Minimal React component that activates the hub bridge hook. */ -function NewCommandHubBridge(): JSX.Element { - useCommandHubBridge() - useEffect(() => { - sendEvent(ANALYTICS_EVENTS.HUB_SCREEN_OPENED, {}, SCREEN.COMMAND_HUB) - }, []) - return <> -} +import { NewCommandHubBridge } from "@/components/NewCommandHubBridge" const rootDiv = document.createElement("div") document.body.appendChild(rootDiv) diff --git a/packages/extension/src/services/analytics.test.ts b/packages/extension/src/services/analytics.test.ts new file mode 100644 index 00000000..5d985c1c --- /dev/null +++ b/packages/extension/src/services/analytics.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest" +import { + ANALYTICS_EVENTS, + getCommandCreateEvent, + getHubAddEvent, + getSelectionCommandEvent, +} from "@/services/analytics" + +describe("getCommandCreateEvent", () => { + it.each([ + ["search", ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + ["aiprompt", ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT], + ["other", ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + ] as const)("maps category %s to %s", (category, expected) => { + expect(getCommandCreateEvent(category)).toBe(expected) + }) +}) + +describe("getHubAddEvent", () => { + it.each([ + ["search", ANALYTICS_EVENTS.HUB_ADD_SEARCH], + ["aiprompt", ANALYTICS_EVENTS.HUB_ADD_AIPROMPT], + ["other", ANALYTICS_EVENTS.HUB_ADD_OTHER], + ] as const)("maps category %s to %s", (category, expected) => { + expect(getHubAddEvent(category)).toBe(expected) + }) +}) + +describe("getSelectionCommandEvent", () => { + it.each([ + ["search", ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH], + ["aiprompt", ANALYTICS_EVENTS.SELECTION_COMMAND_AIPROMPT], + ["other", ANALYTICS_EVENTS.SELECTION_COMMAND_OTHER], + ] as const)("maps category %s to %s", (category, expected) => { + expect(getSelectionCommandEvent(category)).toBe(expected) + }) +}) diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index e5ec5e2b..de1b1c65 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -276,12 +276,28 @@ global.chrome = { removeListener: vi.fn(), }, }, - storage: defaultStorageMocks, + storage: { + ...defaultStorageMocks, + session: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + clear: vi.fn(), + setAccessLevel: vi.fn(), + }, + }, runtime: { sendMessage: vi.fn(), onInstalled: { addListener: vi.fn(), }, + OnInstalledReason: { + INSTALL: "install", + UPDATE: "update", + CHROME_UPDATE: "chrome_update", + SHARED_MODULE_UPDATE: "shared_module_update", + }, + setUninstallURL: vi.fn(), onStartup: { addListener: vi.fn(), }, diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index c3f5a848..75c427fc 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -23,4 +23,8 @@ export const TEST_IDS = { pageActionStep: (type: string) => `page-action-step-${type}`, selectTrigger: (name: string) => `select-trigger-${name.replace(/\./g, "-")}`, selectItem: (name: string) => `select-item-${name.replace(/\./g, "-")}`, + pageRuleAddButton: "page-rule-add-button", + pageRuleUrlPatternInput: "page-rule-url-pattern-input", + pageRuleSaveButton: "page-rule-save-button", + pageRuleEditButton: "page-rule-edit-button", } From 9996f74b543f0108d621fa323fb9fa02ecf9fc5d Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 13 Aug 2026 13:49:40 +0900 Subject: [PATCH 19/37] =?UTF-8?q?Revert:=20selection=5Fcommand=5F*=20?= =?UTF-8?q?=E3=81=AE=E5=88=86=E5=89=B2=E3=82=92=E5=8F=96=E3=82=8A=E6=AD=A2?= =?UTF-8?q?=E3=82=81=E3=80=81selection=5Fcommand=20=E3=81=AB=E6=88=BB?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection Command Hub側のコマンド実行数集計 (ga4-client.ts)がイベント名 "selection_command" を前提にしており、 search/aiprompt/other への分割は不整合を招くため元に戻す。 Co-Authored-By: Claude Sonnet 5 --- .../extension/src/action/executor.test.ts | 4 ++-- packages/extension/src/action/executor.ts | 6 ++--- .../extension/src/services/analytics.test.ts | 11 --------- packages/extension/src/services/analytics.ts | 23 ++++--------------- 4 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/extension/src/action/executor.test.ts b/packages/extension/src/action/executor.test.ts index 99564fe7..faf04849 100644 --- a/packages/extension/src/action/executor.test.ts +++ b/packages/extension/src/action/executor.test.ts @@ -35,7 +35,7 @@ describe("executeAction", () => { }) expect(sendEvent).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, + ANALYTICS_EVENTS.SELECTION_COMMAND, expect.objectContaining({ command_id: "cmd-1", source_type: COMMAND_SOURCE_TYPE.HUB_COMMUNITY, @@ -61,7 +61,7 @@ describe("executeAction", () => { }) expect(sendEvent).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, + ANALYTICS_EVENTS.SELECTION_COMMAND, expect.objectContaining({ command_id: "cmd-2", source_type: COMMAND_SOURCE_TYPE.UNKNOWN, diff --git a/packages/extension/src/action/executor.ts b/packages/extension/src/action/executor.ts index 321308cf..52fcddac 100644 --- a/packages/extension/src/action/executor.ts +++ b/packages/extension/src/action/executor.ts @@ -1,6 +1,6 @@ import type { ExecuteCommandParams } from "@/types" -import { OPEN_MODE, getCommandAnalyticsCategory } from "@/const" -import { sendEvent, getSelectionCommandEvent } from "@/services/analytics" +import { OPEN_MODE } from "@/const" +import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" import { resolveCommandSource } from "@/services/commandSource" export async function executeAction({ @@ -36,7 +36,7 @@ export async function executeAction({ const { sourceType, sourceId } = resolveCommandSource(command) - sendEvent(getSelectionCommandEvent(getCommandAnalyticsCategory(mode)), { + sendEvent(ANALYTICS_EVENTS.SELECTION_COMMAND, { event_label: mode, command_id: command.id, source_type: sourceType, diff --git a/packages/extension/src/services/analytics.test.ts b/packages/extension/src/services/analytics.test.ts index 5d985c1c..e742e3b4 100644 --- a/packages/extension/src/services/analytics.test.ts +++ b/packages/extension/src/services/analytics.test.ts @@ -3,7 +3,6 @@ import { ANALYTICS_EVENTS, getCommandCreateEvent, getHubAddEvent, - getSelectionCommandEvent, } from "@/services/analytics" describe("getCommandCreateEvent", () => { @@ -25,13 +24,3 @@ describe("getHubAddEvent", () => { expect(getHubAddEvent(category)).toBe(expected) }) }) - -describe("getSelectionCommandEvent", () => { - it.each([ - ["search", ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH], - ["aiprompt", ANALYTICS_EVENTS.SELECTION_COMMAND_AIPROMPT], - ["other", ANALYTICS_EVENTS.SELECTION_COMMAND_OTHER], - ] as const)("maps category %s to %s", (category, expected) => { - expect(getSelectionCommandEvent(category)).toBe(expected) - }) -}) diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index d7b8d71c..fa33ffb4 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -31,9 +31,11 @@ export const ANALYTICS_EVENTS = { HUB_ADD_SEARCH: "hub_add_search", HUB_ADD_AIPROMPT: "hub_add_aiprompt", HUB_ADD_OTHER: "hub_add_other", - SELECTION_COMMAND_SEARCH: "selection_command_search", - SELECTION_COMMAND_AIPROMPT: "selection_command_aiprompt", - SELECTION_COMMAND_OTHER: "selection_command_other", + // Kept as a single event (not split by search/aiprompt/other) to stay + // compatible with the execution-count aggregation on the Selection + // Command Hub side, which still reads the unsplit "selection_command" + // event name. + SELECTION_COMMAND: "selection_command", LINK_COMMAND: "link_command", FOLDER_CREATE: "folder_create", PAGE_RULE_CREATE: "page_rule_create", @@ -66,15 +68,6 @@ const HUB_ADD_EVENTS: Record = { other: ANALYTICS_EVENTS.HUB_ADD_OTHER, } -const SELECTION_COMMAND_EVENTS: Record< - CommandAnalyticsCategory, - AnalyticsEventName -> = { - search: ANALYTICS_EVENTS.SELECTION_COMMAND_SEARCH, - aiprompt: ANALYTICS_EVENTS.SELECTION_COMMAND_AIPROMPT, - other: ANALYTICS_EVENTS.SELECTION_COMMAND_OTHER, -} - export function getCommandCreateEvent( category: CommandAnalyticsCategory, ): AnalyticsEventName { @@ -87,12 +80,6 @@ export function getHubAddEvent( return HUB_ADD_EVENTS[category] } -export function getSelectionCommandEvent( - category: CommandAnalyticsCategory, -): AnalyticsEventName { - return SELECTION_COMMAND_EVENTS[category] -} - // https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 export async function sendEvent( From b191bf6f5146db64ca78b1640145684ffd5f075e Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 13 Aug 2026 14:08:15 +0900 Subject: [PATCH 20/37] =?UTF-8?q?Add:=20Hub=E3=83=AD=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AEuserId?= =?UTF-8?q?=E3=82=92GA4=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=81=AB?= =?UTF-8?q?=E9=80=81=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection Command Hubにログインしたユーザーを識別できるよう、Supabaseセッションから user_idを取得してHubUserとして保存し、GA4のsendEventで自動的にuser_idを送信するように した。あわせてclient_id/user_idの取得をchrome.storage.localへの単一get呼び出しに まとめ、session_idの取得と並列化して無駄なstorageアクセスを削減。 Co-Authored-By: Claude Sonnet 5 --- packages/extension/src/services/analytics.ts | 30 +++++++++++++++++-- .../src/services/hub/background.test.ts | 3 +- .../extension/src/services/hub/background.ts | 1 + packages/extension/src/types/index.ts | 1 + 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index fa33ffb4..bfe4314c 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -7,7 +7,7 @@ import { SCREEN, type CommandAnalyticsCategory, } from "@/const" -import { SessionData } from "@/types" +import { SessionData, HubUser } from "@/types" const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect" const GA_DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect" @@ -96,12 +96,17 @@ export async function sendEvent( const endpoint = isDebug ? GA_DEBUG_ENDPOINT : GA_ENDPOINT try { + const [{ clientId, userId }, sessionId] = await Promise.all([ + getClientIdAndUserId(), + getOrCreateSessionId(), + ]) const res = await fetch( `${endpoint}?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`, { method: "POST", body: JSON.stringify({ - client_id: await getOrCreateClientId(), + client_id: clientId, + ...(userId ? { user_id: userId } : {}), events: [ { name: name, @@ -109,7 +114,7 @@ export async function sendEvent( page_title: APP_ID, app_version: VERSION, screen: screen, - session_id: await getOrCreateSessionId(), + session_id: sessionId, engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC, ...params, }, @@ -126,6 +131,25 @@ export async function sendEvent( } } +// Reads CLIENT_ID and HUB_USER in a single chrome.storage.local.get call +// instead of two separate round trips, generating a client_id on first use. +async function getClientIdAndUserId(): Promise<{ + clientId: string + userId?: string +}> { + const result = await chrome.storage.local.get([ + LOCAL_STORAGE_KEY.CLIENT_ID, + LOCAL_STORAGE_KEY.HUB_USER, + ]) + let clientId = result[LOCAL_STORAGE_KEY.CLIENT_ID] as string | undefined + if (!clientId) { + clientId = crypto.randomUUID() + await Storage.set(LOCAL_STORAGE_KEY.CLIENT_ID, clientId) + } + const hubUser = result[LOCAL_STORAGE_KEY.HUB_USER] as HubUser | null + return { clientId, userId: hubUser?.id || undefined } +} + export async function getOrCreateClientId() { let clientId = await Storage.get(LOCAL_STORAGE_KEY.CLIENT_ID) if (!clientId) { diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index d4014e6b..77442348 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -753,7 +753,7 @@ describe("onMessageExternal - RequestInstalledCommand routing", () => { describe("handleSetSession", () => { it("SS-01: sets session, stores HubUser, and responds with result:true", async () => { mockSetSession.mockResolvedValue({ - data: { user: { email: "user@example.com" } }, + data: { user: { id: "user-id-123", email: "user@example.com" } }, error: null, }) const sendResponse = vi.fn() @@ -764,6 +764,7 @@ describe("handleSetSession", () => { refresh_token: "refresh-tok", }) expect(Storage.set).toHaveBeenCalledWith(LOCAL_STORAGE_KEY.HUB_USER, { + id: "user-id-123", name: "user@example.com", image: "", }) diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index f53ed40e..a4bcfdaa 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -530,6 +530,7 @@ export async function handleSetSession( return } const hubUser: HubUser = { + id: data.user.id, name: data.user.email ?? "", image: "", } diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 2fa99431..e2ee4498 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -215,6 +215,7 @@ export type SessionData = { } export type HubUser = { + id: string name: string image: string } From 15498bc4c120a06433480e63a5161f98f96a9e32 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 11:29:39 +0900 Subject: [PATCH 21/37] =?UTF-8?q?Refactor:=20analytics.ts=E3=81=AE?= =?UTF-8?q?=E3=82=AB=E3=83=86=E3=82=B4=E3=83=AA=E5=88=A5=E3=82=A4=E3=83=99?= =?UTF-8?q?=E3=83=B3=E3=83=88=E5=8F=96=E5=BE=97=E5=87=A6=E7=90=86=E3=82=92?= =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCommandCreateEvent/getHubAddEventの構造的重複をジェネリックヘルパー createCategoryEventGetterに統合。あわせてgetCommandAnalyticsCategoryを const.tsからanalytics.tsへ移動し、常にセットで使われる getCommandCreateEvent/getHubAddEvent内に隠蔽することで凝集度を上げた。 Co-Authored-By: Claude Sonnet 5 --- .../editor/CommandList.analytics.test.tsx | 6 +-- .../components/option/editor/CommandList.tsx | 9 +--- packages/extension/src/const.test.ts | 22 ---------- packages/extension/src/const.ts | 14 +------ .../extension/src/services/analytics.test.ts | 41 ++++++++++++++----- packages/extension/src/services/analytics.ts | 38 ++++++++++++----- .../extension/src/services/hub/background.ts | 3 +- 7 files changed, 66 insertions(+), 67 deletions(-) delete mode 100644 packages/extension/src/const.test.ts diff --git a/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx b/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx index 135d3b65..8a00c8a4 100644 --- a/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx +++ b/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx @@ -109,9 +109,9 @@ vi.mock("@/services/settings/settings", () => ({ }, })) -// Use the real getCommandCreateEvent/getCommandAnalyticsCategory mapping so -// this test also verifies CommandList wires the correct category-specific -// event, while keeping the network-calling sendEvent stubbed out. +// Use the real getCommandCreateEvent mapping so this test also verifies +// CommandList wires the correct category-specific event, while keeping the +// network-calling sendEvent stubbed out. vi.mock("@/services/analytics", async (importOriginal) => { const actual = await importOriginal() return { diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index e9f255db..c4356f20 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -31,12 +31,7 @@ import { sendEvent, getCommandCreateEvent, } from "@/services/analytics" -import { - SCREEN, - COMMAND_TYPE, - OPEN_MODE_TYPE_MAP, - getCommandAnalyticsCategory, -} from "@/const" +import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" import type { Command, CommandFolder, SelectionCommand } from "@/types" // Imported services and hooks @@ -246,7 +241,7 @@ export const CommandList = ({ control }: CommandListProps) => { } else { commandArray.append(data as CommandSchemaType) sendEvent( - getCommandCreateEvent(getCommandAnalyticsCategory(data.openMode)), + getCommandCreateEvent(data.openMode), { event_label: data.openMode, }, diff --git a/packages/extension/src/const.test.ts b/packages/extension/src/const.test.ts deleted file mode 100644 index 81791caa..00000000 --- a/packages/extension/src/const.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect } from "vitest" -import { OPEN_MODE, getCommandAnalyticsCategory } from "@/const" - -describe("getCommandAnalyticsCategory", () => { - it.each([ - [OPEN_MODE.POPUP, "search"], - [OPEN_MODE.WINDOW, "search"], - [OPEN_MODE.TAB, "search"], - [OPEN_MODE.BACKGROUND_TAB, "search"], - [OPEN_MODE.SIDE_PANEL, "search"], - [OPEN_MODE.AI_PROMPT, "aiprompt"], - [OPEN_MODE.API, "other"], - [OPEN_MODE.PAGE_ACTION, "other"], - [OPEN_MODE.LINK_POPUP, "other"], - [OPEN_MODE.COPY, "other"], - [OPEN_MODE.GET_TEXT_STYLES, "other"], - [OPEN_MODE.OPTION, "other"], - [OPEN_MODE.ADD_PAGE_RULE, "other"], - ] as const)("maps %s to category %s", (openMode, expected) => { - expect(getCommandAnalyticsCategory(openMode)).toBe(expected) - }) -}) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 2f0eb9c4..3fc9ffd7 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -40,19 +40,7 @@ export const OPEN_MODE_TYPE_MAP = { [OPEN_MODE.GET_TEXT_STYLES]: COMMAND_TYPE.GET_TEXT_STYLES, [OPEN_MODE.OPTION]: COMMAND_TYPE.OPTION, [OPEN_MODE.ADD_PAGE_RULE]: COMMAND_TYPE.OPTION, -} as const - -// Coarse-grained category used to group per-command-type analytics events. -export type CommandAnalyticsCategory = "search" | "aiprompt" | "other" - -export function getCommandAnalyticsCategory( - openMode: OPEN_MODE, -): CommandAnalyticsCategory { - const type = OPEN_MODE_TYPE_MAP[openMode] - if (type === COMMAND_TYPE.SEARCH) return "search" - if (type === COMMAND_TYPE.AI_PROMPT) return "aiprompt" - return "other" -} +} as const satisfies Record // Reverse mapping: COMMAND_TYPE -> OPEN_MODE[] export const COMMAND_TYPE_OPEN_MODES_MAP = { diff --git a/packages/extension/src/services/analytics.test.ts b/packages/extension/src/services/analytics.test.ts index e742e3b4..74040f23 100644 --- a/packages/extension/src/services/analytics.test.ts +++ b/packages/extension/src/services/analytics.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { OPEN_MODE } from "@/const" import { ANALYTICS_EVENTS, getCommandCreateEvent, @@ -7,20 +8,40 @@ import { describe("getCommandCreateEvent", () => { it.each([ - ["search", ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], - ["aiprompt", ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT], - ["other", ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], - ] as const)("maps category %s to %s", (category, expected) => { - expect(getCommandCreateEvent(category)).toBe(expected) + [OPEN_MODE.POPUP, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.WINDOW, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.TAB, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.BACKGROUND_TAB, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.SIDE_PANEL, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.AI_PROMPT, ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT], + [OPEN_MODE.API, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.PAGE_ACTION, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.LINK_POPUP, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.COPY, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.GET_TEXT_STYLES, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.OPTION, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.ADD_PAGE_RULE, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + ] as const)("maps openMode %s to %s", (openMode, expected) => { + expect(getCommandCreateEvent(openMode)).toBe(expected) }) }) describe("getHubAddEvent", () => { it.each([ - ["search", ANALYTICS_EVENTS.HUB_ADD_SEARCH], - ["aiprompt", ANALYTICS_EVENTS.HUB_ADD_AIPROMPT], - ["other", ANALYTICS_EVENTS.HUB_ADD_OTHER], - ] as const)("maps category %s to %s", (category, expected) => { - expect(getHubAddEvent(category)).toBe(expected) + [OPEN_MODE.POPUP, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.WINDOW, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.TAB, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.BACKGROUND_TAB, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.SIDE_PANEL, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.AI_PROMPT, ANALYTICS_EVENTS.HUB_ADD_AIPROMPT], + [OPEN_MODE.API, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.PAGE_ACTION, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.LINK_POPUP, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.COPY, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.GET_TEXT_STYLES, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.OPTION, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.ADD_PAGE_RULE, ANALYTICS_EVENTS.HUB_ADD_OTHER], + ] as const)("maps openMode %s to %s", (openMode, expected) => { + expect(getHubAddEvent(openMode)).toBe(expected) }) }) diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index bfe4314c..58c92607 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -5,7 +5,9 @@ import { APP_ID, VERSION, SCREEN, - type CommandAnalyticsCategory, + OPEN_MODE, + OPEN_MODE_TYPE_MAP, + COMMAND_TYPE, } from "@/const" import { SessionData, HubUser } from "@/types" @@ -51,6 +53,18 @@ export const ANALYTICS_EVENTS = { export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] +// Coarse-grained category used to group per-command-type analytics events. +type CommandAnalyticsCategory = "search" | "aiprompt" | "other" + +function getCommandAnalyticsCategory( + openMode: OPEN_MODE, +): CommandAnalyticsCategory { + const type = OPEN_MODE_TYPE_MAP[openMode] + if (type === COMMAND_TYPE.SEARCH) return "search" + if (type === COMMAND_TYPE.AI_PROMPT) return "aiprompt" + return "other" +} + // Per-command-type analytics events, keyed by the coarse category derived // from getCommandAnalyticsCategory(). const COMMAND_CREATE_EVENTS: Record< @@ -68,17 +82,20 @@ const HUB_ADD_EVENTS: Record = { other: ANALYTICS_EVENTS.HUB_ADD_OTHER, } -export function getCommandCreateEvent( - category: CommandAnalyticsCategory, -): AnalyticsEventName { - return COMMAND_CREATE_EVENTS[category] +// Wraps a category->event lookup table into a getter keyed directly by +// OPEN_MODE, so callers don't need to know about CommandAnalyticsCategory. +function createCategoryEventGetter( + events: Record, +) { + return (openMode: OPEN_MODE): AnalyticsEventName => + events[getCommandAnalyticsCategory(openMode)] } -export function getHubAddEvent( - category: CommandAnalyticsCategory, -): AnalyticsEventName { - return HUB_ADD_EVENTS[category] -} +export const getCommandCreateEvent = createCategoryEventGetter( + COMMAND_CREATE_EVENTS, +) + +export const getHubAddEvent = createCategoryEventGetter(HUB_ADD_EVENTS) // https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 @@ -121,6 +138,7 @@ export async function sendEvent( }, ], }), + keepalive: true, }, ) if (isDebug) { diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index a4bcfdaa..488acdef 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -5,7 +5,6 @@ import { OPTION_PAGE_PATH, COMMAND_SOURCE_TYPE, SCREEN, - getCommandAnalyticsCategory, } from "@/const" import type { Sender } from "@/services/ipc" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" @@ -368,7 +367,7 @@ export async function handleAddCommand( await Settings.addCommands([cmd]) console.debug("[handleAddCommand] Saved command id:", cmd.id) await sendEvent( - getHubAddEvent(getCommandAnalyticsCategory(cmd.openMode)), + getHubAddEvent(cmd.openMode), { event_label: cmd.openMode, source_type: sourceInfo.sourceType, From 4d9f92f9895108466158524ad04bca846519f676 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 11:40:40 +0900 Subject: [PATCH 22/37] Add: Attach client_id to uninstall survey URL for correlation analysis Closes #449 Co-Authored-By: Claude Sonnet 5 --- packages/extension/src/background_script.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 04311103..210ab4fd 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -22,7 +22,11 @@ import { execute } from "@/action/background" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" -import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { + ANALYTICS_EVENTS, + sendEvent, + getOrCreateClientId, +} from "@/services/analytics" import * as HubBackground from "@/services/hub/background" import { importIf } from "@import-if" @@ -452,8 +456,11 @@ chrome.runtime.onInstalled.addListener(async (details) => { details.reason === chrome.runtime.OnInstalledReason.INSTALL || details.reason === chrome.runtime.OnInstalledReason.UPDATE ) { - // Set uninstall survey URL - chrome.runtime.setUninstallURL(`${NEW_HUB_URL}/uninstall`) + // Set uninstall survey URL with client_id for analysis + const clientId = await getOrCreateClientId() + chrome.runtime.setUninstallURL( + `${NEW_HUB_URL}/uninstall?client_id=${clientId}`, + ) } // Check for daily backup on startup From cceddd4976219fb41d9b1e8171e2dda7e86adf83 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 11:54:39 +0900 Subject: [PATCH 23/37] Fix: Isolate uninstall URL setup so client_id lookup failures don't skip backup checks Wraps the client_id fetch/setUninstallURL call in its own try/catch, falling back to the plain uninstall URL on failure, and adds tests that exercise the onInstalled listener end to end. Co-Authored-By: Claude Sonnet 5 --- .../extension/src/background_script.test.ts | 74 +++++++++++++++++++ packages/extension/src/background_script.ts | 17 +++-- packages/extension/src/test/setup.ts | 14 +++- 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 587632e5..50d0e5ba 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { BgCommand } from "@/services/ipc" +import { NEW_HUB_URL } from "@/const" // Mock dependencies vi.mock("@/services/settings/enhancedSettings") @@ -567,3 +568,76 @@ describe("Popup Auto-Close Delay", () => { expect(mockRemoveWindow).not.toHaveBeenCalled() }) }) + +describe("Uninstall URL (onInstalled)", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("IN-01: should set uninstall URL with client_id on install", async () => { + const mockGetOrCreateClientId = vi.fn().mockResolvedValue("test-client-id") + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: {}, + sendEvent: vi.fn(), + getOrCreateClientId: mockGetOrCreateClientId, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + expect(listenerCalls.length).toBeGreaterThan(0) + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockGetOrCreateClientId).toHaveBeenCalled() + expect(chrome.runtime.setUninstallURL).toHaveBeenCalledWith( + `${NEW_HUB_URL}/uninstall?client_id=test-client-id`, + ) + }) + + it("IN-02: should fall back to the uninstall URL without client_id when getOrCreateClientId fails, without skipping the rest of initialization", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}) + const mockGetOrCreateClientId = vi + .fn() + .mockRejectedValue(new Error("Quota exceeded")) + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: {}, + sendEvent: vi.fn(), + getOrCreateClientId: mockGetOrCreateClientId, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Falls back to the URL without client_id instead of leaving the + // uninstall URL unset. + expect(chrome.runtime.setUninstallURL).toHaveBeenCalledWith( + `${NEW_HUB_URL}/uninstall`, + ) + // The client_id failure must not propagate to the outer catch, which + // would otherwise skip the daily/weekly backup checks that follow. + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + "Error during onInstalled initialization:", + expect.anything(), + ) + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 210ab4fd..30f15161 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -456,11 +456,18 @@ chrome.runtime.onInstalled.addListener(async (details) => { details.reason === chrome.runtime.OnInstalledReason.INSTALL || details.reason === chrome.runtime.OnInstalledReason.UPDATE ) { - // Set uninstall survey URL with client_id for analysis - const clientId = await getOrCreateClientId() - chrome.runtime.setUninstallURL( - `${NEW_HUB_URL}/uninstall?client_id=${clientId}`, - ) + // Set uninstall survey URL with client_id for analysis. + // Wrapped in its own try/catch so a failure here (e.g. storage quota + // error) does not skip the backup checks below. + try { + const clientId = await getOrCreateClientId() + chrome.runtime.setUninstallURL( + `${NEW_HUB_URL}/uninstall?client_id=${clientId}`, + ) + } catch (error) { + console.error("Failed to set uninstall URL with client_id:", error) + chrome.runtime.setUninstallURL(`${NEW_HUB_URL}/uninstall`) + } } // Check for daily backup on startup diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index e5ec5e2b..cb710470 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -276,9 +276,21 @@ global.chrome = { removeListener: vi.fn(), }, }, - storage: defaultStorageMocks, + storage: { + ...defaultStorageMocks, + session: { + setAccessLevel: vi.fn(), + }, + }, runtime: { sendMessage: vi.fn(), + setUninstallURL: vi.fn(), + OnInstalledReason: { + INSTALL: "install", + UPDATE: "update", + CHROME_UPDATE: "chrome_update", + SHARED_MODULE_UPDATE: "shared_module_update", + }, onInstalled: { addListener: vi.fn(), }, From ef25c7373b30e46bca96816e3dfeac77ad2422bc Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 13:45:50 +0900 Subject: [PATCH 24/37] =?UTF-8?q?Refactor:=20HUB=5FREGISTERED=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E3=82=92=20isHubR?= =?UTF-8?q?egistered()=20=E3=81=AB=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShareButton.tsx と hub/background.ts で個別に HUB_REGISTERED を判定していたため、片方だけ条件を変更すると UI 表示とバックグラウンドの実際の挙動が不整合になる恐れがあった。 Co-Authored-By: Claude Sonnet 5 --- .../src/components/option/ShareButton.tsx | 6 +-- .../src/services/hub/background.test.ts | 37 +++++++++++++++---- .../extension/src/services/hub/background.ts | 5 +-- packages/extension/src/services/hubShare.ts | 11 ++++++ 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index e877f0f0..1b635a8e 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -7,9 +7,9 @@ import { shareCommandToHub, getHubLocale, isHubShareable, + isHubRegistered, } from "@/services/hubShare" import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" -import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import { NEW_HUB_URL, SCREEN } from "@/const" import type { SelectionCommand } from "@/types" import { TEST_IDS } from "@/testIds" @@ -64,9 +64,7 @@ export const ShareButton = ({ // 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, - ) + const registered = await isHubRegistered() if (!registered) { setStatus("idle") return diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 77442348..16bff316 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -23,6 +23,7 @@ vi.mock("@/services/storage", () => ({ set: vi.fn(), get: vi.fn(), updateCommands: vi.fn(), + addListener: vi.fn(), }, LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", @@ -991,6 +992,10 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check (via isHubRegistered's own + // await), 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( @@ -1016,6 +1021,10 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check (via isHubRegistered's own + // await), 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( @@ -1042,8 +1051,11 @@ 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. + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -1089,8 +1101,11 @@ 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. + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -1142,8 +1157,11 @@ 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. + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -1197,8 +1215,11 @@ 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. + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() await Promise.resolve() await Promise.resolve() diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 488acdef..dbbabbc4 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -9,6 +9,7 @@ import { import type { Sender } from "@/services/ipc" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import type { SubmitCommandInput } from "@/services/hubShare" +import { isHubRegistered } from "@/services/hubShare" import type { HubUser, CommandFromHub } from "@/types" import { Settings } from "@/services/settings/settings" import { @@ -80,9 +81,7 @@ export const shareCommandToHub = ( const share = async () => { try { - const registered = await Storage.get( - LOCAL_STORAGE_KEY.HUB_REGISTERED, - ) + const registered = await isHubRegistered() 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, diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index ecd59324..c3af64a0 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -9,6 +9,7 @@ import { import { getAiServicesFallback } from "@/services/aiPromptFallback" import { isAiPromptCommand, isPageActionCommand } from "@/lib/utils" import { Ipc, BgCommand } from "@/services/ipc" +import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import type { SelectionCommand, SearchCommand } from "@/types" const HUB_SHAREABLE_SOURCE_TYPES = new Set([ @@ -92,6 +93,16 @@ export function isHubShareable(command: SelectionCommand): boolean { ) } +/** + * Determines whether the user has ever signed in to the Hub. + * Used both to decide the ShareButton's success/idle display and to decide + * whether shareCommandToHub() opens the dashboard or the sign-up page, + * so keep this the single source of truth for that check. + */ +export async function isHubRegistered(): Promise { + return !!(await Storage.get(LOCAL_STORAGE_KEY.HUB_REGISTERED)) +} + // ---- Share main logic ------------------------------------------------------ export function shareCommandToHub(command: SelectionCommand): boolean { From 5451c6580ca890964e3c35ea3734ffc1fe025173 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 15:34:24 +0900 Subject: [PATCH 25/37] =?UTF-8?q?Refactor:=20AiPrompt.execute=E3=82=92?= =?UTF-8?q?=E3=83=95=E3=82=A7=E3=83=BC=E3=82=BA=E3=81=94=E3=81=A8=E3=81=AE?= =?UTF-8?q?=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E9=96=A2=E6=95=B0=E3=81=AB?= =?UTF-8?q?=E5=88=86=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 360行の単一関数に検証・要件分析・ステップ構築・ディスパッチが混在し、 Start/Endステップの組み立てが両分岐で重複していたため、処理の流れを 追いやすくし、ステップ生成の定型コードを createStep() に一本化した。 挙動は変更していない。 Co-Authored-By: Claude Sonnet 5 --- packages/extension/src/action/aiPrompt.ts | 708 +++++++++++++--------- 1 file changed, 419 insertions(+), 289 deletions(-) diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index af0a762f..1dc0f846 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -17,7 +17,13 @@ import { SelectorType, } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" -import type { ExecuteCommandParams, PageActionStep, UrlParam } from "@/types" +import type { + ExecuteCommandParams, + PageActionStep, + UrlParam, + AiService, + AiPromptOption, +} from "@/types" import type { OpenAndRunProps } from "@/services/pageAction/background" import type { OpenSidePanelProps } from "@/services/chrome" import { findAiService } from "@/services/aiPrompt" @@ -62,6 +68,390 @@ const toPageActionMode = (mode: OPEN_MODE): PAGE_ACTION_OPEN_MODE => { } } +// Builds a PageActionStep, filling in the id/skipRenderWait boilerplate that +// every step shares. +const createStep = ( + param: PageActionStep["param"], + opts: { delayMs?: number; skipRenderWait?: boolean } = {}, +): PageActionStep => ({ + id: generateRandomID(), + delayMs: opts.delayMs ?? 0, + skipRenderWait: opts.skipRenderWait ?? false, + param, +}) + +const createStartStep = (): PageActionStep => + createStep({ + type: PAGE_ACTION_CONTROL.start, + label: "Start", + mode: "aiPrompt", + }) + +const createEndStep = (): PageActionStep => + createStep({ type: PAGE_ACTION_CONTROL.end, label: "End" }) + +type PromptRequirements = { + needClipboard: boolean + needPageHtml: boolean + pageHtml: string | undefined + needSelectionHtml: boolean + selectionHtml: string | undefined + needFilePaste: boolean + useQueryUrl: boolean +} + +// Determines which optional inputs (clipboard, page/selection HTML) the +// prompt needs, and whether the query-URL approach can be used at all. +const analyzePromptRequirements = ( + aiPromptOption: AiPromptOption, + service: AiService, +): PromptRequirements => { + // Checks if any step requires clipboard data + const needClipboard = aiPromptOption.prompt.includes( + toInsertTemplate(INSERT.CLIPBOARD), + ) + + // Detect HTML placeholders that require file-paste upload instead of text embedding. + // File paste cannot be used with queryUrl mode (URL length limit), so these force + // the DOM input path. + const needPageHtml = aiPromptOption.prompt.includes( + toInsertTemplate(INSERT.PAGE_HTML), + ) + 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. + const needSelectionHtml = + !needPageHtml && + aiPromptOption.prompt.includes(toInsertTemplate(INSERT.SELECTION_HTML)) + const selectionHtml = needSelectionHtml ? getSelectionHtml() : undefined + + const needFilePaste = needPageHtml || needSelectionHtml + + // Use URL query input when the service supports it and neither clipboard nor + // file-paste content is needed. Both clipboard and HTML content require the DOM + // input path since they can't be embedded in a URL safely. + const useQueryUrl = + isValidString(service.queryUrl) && !needClipboard && !needFilePaste + + return { + needClipboard, + needPageHtml, + pageHtml, + needSelectionHtml, + selectionHtml, + needFilePaste, + useQueryUrl, + } +} + +// Builds the (optional) Submit step for the query-URL approach. When +// autoSubmit is true a fallback click is still added in case the service +// fails to auto-process the prompt after navigation. +const buildQueryUrlSubmitStep = ( + service: AiService, + submitSelector: string, + inputSelector: string, +): PageActionStep[] => { + if (!service.autoSubmit && submitSelector.length === 0) { + console.warn( + `[AiPrompt] queryUrl mode: submitSelectors is empty for "${service.id}" but autoSubmit is false. Submit step will be skipped.`, + ) + } + + // When autoSubmit is true (e.g. ChatGPT, Perplexity) the service is + // expected to process the prompt automatically after navigation. But + // this can silently fail to fire (e.g. while ChatGPT is re-validating + // the session right after opening the URL), leaving the prompt sitting + // unsent in the input. As a fallback, add a Submit click step that only + // fires if the input still contains the unsent prompt. + if ( + service.autoSubmit && + submitSelector.length > 0 && + inputSelector.length > 0 + ) { + console.debug( + `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}". Adding fallback Submit step.`, + ) + return [ + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit (fallback)", + selector: submitSelector, + selectorType: SelectorType.css, + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.skip, + conditionType: PAGE_ACTION_CONDITION_TYPE.empty, + selector: inputSelector, + selectorType: SelectorType.css, + }, + }, + { delayMs: 1000 }, + ), + ] + } + + if (!service.autoSubmit && submitSelector.length > 0) { + return [ + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + }, + { delayMs: 200 }, + ), + ] + } + + if (service.autoSubmit) { + console.warn( + `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}" but submitSelectors or inputSelectors is empty. Fallback Submit step will be skipped.`, + ) + } + return [] +} + +// Query URL approach: pre-expand the prompt and navigate to the service's query URL. +const buildQueryUrlSteps = ( + service: AiService, + aiPromptOption: AiPromptOption, + selectionText: string, + pageUrl: string | undefined, +): { steps: PageActionStep[]; urlParam: UrlParam; serviceUrl: string } => { + // Pre-expand the prompt template with synchronously available variables. + // INSERT.CLIPBOARD is intentionally excluded here: clipboard text is not + // available in the content script context and must be read asynchronously + // in the background. When the prompt contains {{Clipboard}}, useQueryUrl + // is false and the DOM input approach is used instead. + const expandedPrompt = safeInterpolate(aiPromptOption.prompt, { + [InsertSymbol[INSERT.SELECTED_TEXT]]: selectionText, + [InsertSymbol[INSERT.URL]]: pageUrl ?? "", + [InsertSymbol[INSERT.LANG]]: getUILanguage(), + }) + + const finalPrompt = service.urlToMarkdown + ? convertUrlsToMarkdown(expandedPrompt) + : expandedPrompt + + const urlParam: UrlParam = { + searchUrl: service.queryUrl!, + selectionText: finalPrompt, + useClipboard: false, + } + // Resolve the final URL for cases that require a plain string (e.g. side panel). + const serviceUrl = toUrl(urlParam) as string + + const submitSelector = service.submitSelectors.join(", ") + const inputSelector = service.inputSelectors.join(", ") + const submitStep = buildQueryUrlSubmitStep( + service, + submitSelector, + inputSelector, + ) + + const steps: PageActionStep[] = [ + createStartStep(), + ...submitStep, + createEndStep(), + ] + + return { steps, urlParam, serviceUrl } +} + +// When HTML placeholders are present, build filePaste steps that upload the +// HTML as a text file attachment before typing the prompt. +const buildFilePasteSteps = ( + inputSelector: string, + needPageHtml: boolean, + needSelectionHtml: boolean, +): PageActionStep[] => { + const pageTitle = + document.title + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\p{Cc}/gu, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, 64) || "page" + + const filePasteSteps: PageActionStep[] = [] + if (needPageHtml) { + filePasteSteps.push( + createStep( + { + type: PAGE_ACTION_EVENT.filePaste, + label: "Paste page HTML", + selector: inputSelector, + selectorType: SelectorType.css, + value: toInsertTemplate(INSERT.PAGE_HTML), + fileName: `${pageTitle}.html`, + fileType: "text/html", + }, + { delayMs: 200 }, + ), + ) + } + if (needSelectionHtml) { + filePasteSteps.push( + createStep( + { + type: PAGE_ACTION_EVENT.filePaste, + label: "Paste selection HTML", + selector: inputSelector, + selectorType: SelectorType.css, + value: toInsertTemplate(INSERT.SELECTION_HTML), + fileName: `${pageTitle}.html`, + fileType: "text/html", + }, + { delayMs: 200 }, + ), + ) + } + return filePasteSteps +} + +// DOM input approach: type the prompt into the service's input element. +const buildDomInputSteps = ( + service: AiService, + aiPromptOption: AiPromptOption, + selectionText: string, + needClipboard: boolean, + useClipboard: boolean | undefined, + needPageHtml: boolean, + needSelectionHtml: boolean, + needFilePaste: boolean, +): { steps: PageActionStep[]; urlParam: UrlParam; serviceUrl: string } => { + const inputSelector = service.inputSelectors.join(", ") + const submitSelector = service.submitSelectors.join(", ") + + const serviceUrl = service.url + const urlParam: UrlParam = { + searchUrl: service.url, + selectionText, + useClipboard: needClipboard || (useClipboard ?? false), + } + + // When HTML placeholders are present, build filePaste steps that upload + // the HTML as a text file attachment before typing the prompt. + // The placeholders are stripped from the input step value so the AI + // receives the file as an attachment and the remaining text as the prompt. + const filePasteSteps = buildFilePasteSteps( + inputSelector, + needPageHtml, + needSelectionHtml, + ) + + // Remove the HTML placeholders from the prompt value. + const promptValue = needFilePaste + ? aiPromptOption.prompt + .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") + .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") + .trim() + : aiPromptOption.prompt + + const steps: PageActionStep[] = [ + createStartStep(), + ...filePasteSteps, + createStep( + { + type: PAGE_ACTION_EVENT.input, + label: "Input prompt", + selector: inputSelector, + selectorType: SelectorType.css, + value: promptValue, + }, + { delayMs: 200 }, + ), + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, + conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, + selector: submitSelector, + selectorType: SelectorType.css, + timeout: PAGE_ACTION_TIMEOUT * 2, // Allow more time for click + }, + }, + { delayMs: 200 }, + ), + createEndStep(), + ] + + return { steps, urlParam, serviceUrl } +} + +// Handles side panel mode: stores pending steps in session storage, then opens +// the side panel. The background onConnect handler will pick up the pending +// steps when the side panel content script establishes a port connection. +// Clipboard reading is deferred to the background script context to avoid +// browser security restrictions on navigator.clipboard in content scripts. +const runSidePanelAction = async (params: { + serviceUrl: string + steps: PageActionStep[] + selectionText: string + pageUrl: string | undefined + useQueryUrl: boolean + needClipboard: boolean + useClipboard: boolean | undefined + pageHtml: string | undefined + selectionHtml: string | undefined +}): Promise => { + const { + serviceUrl, + steps, + selectionText, + pageUrl, + useQueryUrl, + needClipboard, + useClipboard, + pageHtml, + selectionHtml, + } = params + + const pending: SidePanelPendingAction = { + url: serviceUrl, + steps, + selectedText: selectionText, + srcUrl: pageUrl ?? "", + clipboardText: "", + useClipboard: !useQueryUrl && (needClipboard || (useClipboard ?? false)), + pageHtml, + selectionHtml, + } + try { + await Storage.set( + SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, + pending, + ) + } catch (e) { + console.error("Failed to store pending side panel action:", e) + return + } + Ipc.send(BgCommand.openSidePanel, { + url: serviceUrl, + }) +} + +// Resolves the effective open mode, swapping TAB/WINDOW when the secondary +// action (e.g. modifier-key click) is used. +const resolveOpenMode = ( + baseOpenMode: OPEN_MODE, + useSecondary: boolean | undefined, +): PAGE_ACTION_OPEN_MODE => { + const baseMode = toPageActionMode(baseOpenMode) + if (!useSecondary) return baseMode + if (baseMode === PAGE_ACTION_OPEN_MODE.TAB) { + return PAGE_ACTION_OPEN_MODE.WINDOW + } + return PAGE_ACTION_OPEN_MODE.TAB +} + export const AiPrompt = { async execute({ selectionText, @@ -89,295 +479,43 @@ export const AiPrompt = { return } - // Checks if any step requires clipboard data - const needClipboard = aiPromptOption.prompt.includes( - toInsertTemplate(INSERT.CLIPBOARD), - ) - - // Detect HTML placeholders that require file-paste upload instead of text embedding. - // File paste cannot be used with queryUrl mode (URL length limit), so these force - // the DOM input path. - const needPageHtml = aiPromptOption.prompt.includes( - toInsertTemplate(INSERT.PAGE_HTML), - ) - 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. - const needSelectionHtml = - !needPageHtml && - aiPromptOption.prompt.includes(toInsertTemplate(INSERT.SELECTION_HTML)) - const selectionHtml = needSelectionHtml ? getSelectionHtml() : undefined - - const needFilePaste = needPageHtml || needSelectionHtml - - // Use URL query input when the service supports it and neither clipboard nor - // file-paste content is needed. Both clipboard and HTML content require the DOM - // input path since they can't be embedded in a URL safely. - const useQueryUrl = - isValidString(service.queryUrl) && !needClipboard && !needFilePaste - - let steps: PageActionStep[] - let serviceUrl: string - let urlParam: UrlParam - - if (useQueryUrl) { - // Pre-expand the prompt template with synchronously available variables. - // INSERT.CLIPBOARD is intentionally excluded here: clipboard text is not - // available in the content script context and must be read asynchronously - // in the background. When the prompt contains {{Clipboard}}, useQueryUrl - // is false and the DOM input approach is used instead. - const expandedPrompt = safeInterpolate(aiPromptOption.prompt, { - [InsertSymbol[INSERT.SELECTED_TEXT]]: selectionText, - [InsertSymbol[INSERT.URL]]: pageUrl ?? "", - [InsertSymbol[INSERT.LANG]]: getUILanguage(), - }) + const { + needClipboard, + needPageHtml, + pageHtml, + needSelectionHtml, + selectionHtml, + needFilePaste, + useQueryUrl, + } = analyzePromptRequirements(aiPromptOption, service) - const finalPrompt = service.urlToMarkdown - ? convertUrlsToMarkdown(expandedPrompt) - : expandedPrompt - - urlParam = { - searchUrl: service.queryUrl!, - selectionText: finalPrompt, - useClipboard: false, - } - // Resolve the final URL for cases that require a plain string (e.g. side panel). - serviceUrl = toUrl(urlParam) as string - - // Build steps without the DOM input step. - const submitSelector = service.submitSelectors.join(", ") - const inputSelector = service.inputSelectors.join(", ") - if (!service.autoSubmit && submitSelector.length === 0) { - console.warn( - `[AiPrompt] queryUrl mode: submitSelectors is empty for "${service.id}" but autoSubmit is false. Submit step will be skipped.`, + const { steps, urlParam, serviceUrl } = useQueryUrl + ? buildQueryUrlSteps(service, aiPromptOption, selectionText, pageUrl) + : buildDomInputSteps( + service, + aiPromptOption, + selectionText, + needClipboard, + useClipboard, + needPageHtml, + needSelectionHtml, + needFilePaste, ) - } - - // When autoSubmit is true (e.g. ChatGPT, Perplexity) the service is - // expected to process the prompt automatically after navigation. But - // this can silently fail to fire (e.g. while ChatGPT is re-validating - // the session right after opening the URL), leaving the prompt sitting - // unsent in the input. As a fallback, add a Submit click step that only - // fires if the input still contains the unsent prompt. - let submitStep: PageActionStep[] = [] - if ( - service.autoSubmit && - submitSelector.length > 0 && - inputSelector.length > 0 - ) { - console.debug( - `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}". Adding fallback Submit step.`, - ) - submitStep = [ - { - id: generateRandomID(), - delayMs: 1000, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit (fallback)", - selector: submitSelector, - selectorType: SelectorType.css, - condition: { - actionType: PAGE_ACTION_CONDITION_ACTION.skip, - conditionType: PAGE_ACTION_CONDITION_TYPE.empty, - selector: inputSelector, - selectorType: SelectorType.css, - }, - }, - }, - ] - } else if (!service.autoSubmit && submitSelector.length > 0) { - submitStep = [ - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit", - selector: submitSelector, - selectorType: SelectorType.css, - }, - }, - ] - } else if (service.autoSubmit) { - console.warn( - `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}" but submitSelectors or inputSelectors is empty. Fallback Submit step will be skipped.`, - ) - } - - steps = [ - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.start, - label: "Start", - mode: "aiPrompt", - }, - }, - ...submitStep, - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.end, - label: "End", - }, - }, - ] - } else { - // DOM input approach: type the prompt into the service's input element. - const inputSelector = service.inputSelectors.join(", ") - const submitSelector = service.submitSelectors.join(", ") - - serviceUrl = service.url - urlParam = { - searchUrl: service.url, - selectionText, - useClipboard: needClipboard || (useClipboard ?? false), - } - - // When HTML placeholders are present, build filePaste steps that upload - // the HTML as a text file attachment before typing the prompt. - // The placeholders are stripped from the input step value so the AI - // receives the file as an attachment and the remaining text as the prompt. - const pageTitle = - document.title - .replace(/[<>:"/\\|?*]/g, "") - .replace(/\p{Cc}/gu, "") - .trim() - .replace(/\s+/g, "-") - .slice(0, 64) || "page" - - const filePasteSteps: PageActionStep[] = [] - if (needPageHtml) { - filePasteSteps.push({ - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.filePaste, - label: "Paste page HTML", - selector: inputSelector, - selectorType: SelectorType.css, - value: toInsertTemplate(INSERT.PAGE_HTML), - fileName: `${pageTitle}.html`, - fileType: "text/html", - }, - }) - } - if (needSelectionHtml) { - filePasteSteps.push({ - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.filePaste, - label: "Paste selection HTML", - selector: inputSelector, - selectorType: SelectorType.css, - value: toInsertTemplate(INSERT.SELECTION_HTML), - fileName: `${pageTitle}.html`, - fileType: "text/html", - }, - }) - } - - // Remove the HTML placeholders from the prompt value. - const promptValue = needFilePaste - ? aiPromptOption.prompt - .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") - .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") - .trim() - : aiPromptOption.prompt - - steps = [ - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.start, - label: "Start", - mode: "aiPrompt", - }, - }, - ...filePasteSteps, - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.input, - label: "Input prompt", - selector: inputSelector, - selectorType: SelectorType.css, - value: promptValue, - }, - }, - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit", - selector: submitSelector, - selectorType: SelectorType.css, - condition: { - actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, - conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, - selector: submitSelector, - selectorType: SelectorType.css, - timeout: PAGE_ACTION_TIMEOUT * 2, // Allow more time for click - }, - }, - }, - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.end, - label: "End", - }, - }, - ] - } // Handle side panel mode: store pending steps in session storage, then open // the side panel. The background onConnect handler will pick up the pending // steps when the side panel content script establishes a port connection. - // Clipboard reading is deferred to the background script context to avoid - // browser security restrictions on navigator.clipboard in content scripts. if (aiPromptOption.openMode === OPEN_MODE.SIDE_PANEL) { - const pending: SidePanelPendingAction = { - url: serviceUrl, + await runSidePanelAction({ + serviceUrl, steps, - selectedText: selectionText, - srcUrl: pageUrl ?? "", - clipboardText: "", - useClipboard: - !useQueryUrl && (needClipboard || (useClipboard ?? false)), + selectionText, + pageUrl, + useQueryUrl, + needClipboard, + useClipboard, pageHtml, selectionHtml, - } - try { - await Storage.set( - SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, - pending, - ) - } catch (e) { - console.error("Failed to store pending side panel action:", e) - return - } - Ipc.send(BgCommand.openSidePanel, { - url: serviceUrl, }) return } @@ -388,15 +526,7 @@ export const AiPrompt = { return } - const baseMode = toPageActionMode(aiPromptOption.openMode) - const openMode = useSecondary - ? baseMode === PAGE_ACTION_OPEN_MODE.TAB - ? PAGE_ACTION_OPEN_MODE.WINDOW - : baseMode === PAGE_ACTION_OPEN_MODE.WINDOW - ? PAGE_ACTION_OPEN_MODE.TAB - : PAGE_ACTION_OPEN_MODE.TAB - : baseMode - + const openMode = resolveOpenMode(aiPromptOption.openMode, useSecondary) const windowPosition = await getWindowPosition() Ipc.send(BgCommand.openAndRunPageAction, { From 93bc4b96f84fffb947e95bff82fe0e1a66060df9 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 14 Aug 2026 17:21:57 +0900 Subject: [PATCH 26/37] Update: Ignore olx.pt from e2e testing. --- packages/extension/e2e/generated-command-urls.ts | 1 - packages/extension/scripts/e2e-ignore-urls.txt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/e2e/generated-command-urls.ts b/packages/extension/e2e/generated-command-urls.ts index 497f461f..32703907 100644 --- a/packages/extension/e2e/generated-command-urls.ts +++ b/packages/extension/e2e/generated-command-urls.ts @@ -33,7 +33,6 @@ export const COMMAND_URLS: UrlEntry[] = [ { title: "AliExpress", locale: "es", searchUrl: "https://es.aliexpress.com/w/wholesale-%s.html" }, { title: "Buscar comandos en Hub", locale: "es", searchUrl: "https://selection-command.com/es?q=%pageUrl" }, { title: "Pesquisar comandos no Hub", locale: "pt-br", searchUrl: "https://selection-command.com/pt-BR?q=%pageUrl" }, - { title: "OLX", locale: "pt", searchUrl: "https://www.olx.pt/ads/q-%s/" }, { title: "Pesquisar comandos no Hub", locale: "pt", searchUrl: "https://selection-command.com/pt-PT?q=%pageUrl" }, { title: "Hub पर कमांड खोजें", locale: "hi", searchUrl: "https://selection-command.com/hi?q=%pageUrl" }, { title: "Tokopedia", locale: "id", searchUrl: "https://www.tokopedia.com/search?st=product&q=%s" }, diff --git a/packages/extension/scripts/e2e-ignore-urls.txt b/packages/extension/scripts/e2e-ignore-urls.txt index 54f871f4..132a482e 100644 --- a/packages/extension/scripts/e2e-ignore-urls.txt +++ b/packages/extension/scripts/e2e-ignore-urls.txt @@ -14,3 +14,4 @@ https://www.zhihu.com/search?q=%s https://www.ebay.de/sch/i.html?_nkw=%s https://www.ebay.es/sch/i.html?_nkw=%s https://www.ebay.it/sch/i.html?_nkw=%s +https://www.olx.pt/ads/q-%s/ From 7560d0cfd1376adbe1f18d8614da8fa3549d2595 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 17 Aug 2026 15:23:49 +0900 Subject: [PATCH 27/37] Fix: Fix toast jitter on hover. --- packages/extension/src/components/ReviewRequestToast.tsx | 2 +- packages/extension/src/components/option/HubShareToast.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/components/ReviewRequestToast.tsx b/packages/extension/src/components/ReviewRequestToast.tsx index fd01822f..cd8b9811 100644 --- a/packages/extension/src/components/ReviewRequestToast.tsx +++ b/packages/extension/src/components/ReviewRequestToast.tsx @@ -29,7 +29,7 @@ export function showReviewRequestToast(onAccept: () => void): void { toast.custom( (toastId) => ( -
+
diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx index 8123a5f3..6387c12d 100644 --- a/packages/extension/src/components/option/HubShareToast.tsx +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -40,7 +40,7 @@ function showHubShareToastNow( toast.custom( (toastId) => ( -
+
Date: Mon, 17 Aug 2026 15:26:16 +0900 Subject: [PATCH 28/37] Update: Unify background color on hover. --- .../public/SelectionCommandHub_new.png | Bin 19373 -> 22765 bytes .../editor/CommandTypeSelectionDialog.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/public/SelectionCommandHub_new.png b/packages/extension/public/SelectionCommandHub_new.png index d6a24eb678b66087f7d6373462e54ca3d18e3655..005227e2cf3db8c9e26110e92c3bcf9df1d46c1d 100644 GIT binary patch literal 22765 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%0Dfw zr$!9{O??0vf)2R?2~%2`kr);X#^#sl0Nm2?Z^@zbNC?>Qp*Ov)zx`*HTkKFGz~}_g zfj-~aPKTdC!xu}Zhc~AwUEiCr?ez;v2K=3oP;mo}RK=v06=lK~`zaK4?=JL?c6Z;K zl6T9U(ox13GrTRKOfD)0LV&trl`fQT>!7GtB&gv^7Z_EChD^}=g&8+45*Nw=_V*8J_07N!Dh7OWo-0(C7I>pnND|n$pBw*l z_MxY>5!+syBiS58DrpNNf7?1>WzVC=@q(Iq*;3l-u)Rr(4TF2lgj$I1B<2cd*pf$m zr+oHho#R&+4+&L?W)!ZKWYNbYhYH$LM_TK_PCE7MXy{c;+>ixPRLm49LPsE)_$@^N zxO7HGScF^Q`` zgrfI555~KOV5!RLE-D7TAK2`ueipHW>%=D+t?qpCUFW6hEpDtpR}JL)M?xu;Kk4C zETfQ1O?9;=ePP0ZIh^an11sKaji-7y*>kL!S*>LENgHCZB2>OQ&Q!MtMSkx=O;^b zyU9?f(bNk9tKSKb_MiS9SLSe67T`%2qdLaCe5<6;!h~D|4~z5q<6>39MZvT%Riq%% zpVh~lIG@MqkvN~g^)eq4rdhD9!#Aslv*%$B0BJLb;lwMG!)FxxOll*RJZS1tr@o@^ z$YC1{YR;wtp(XRhRMy$tqtm4g-#Fe-Taf~0(0HfQ3Lr?F68#k^>cn;tN`2d*O!dsVe46>k-j1#{Ep{E`Zl2Wu|7eeu$<6x7|*jA z6yHpF>gMT#M4o*@tFE{gY#A14*8hulj@g41zF=LKJAPQTzNp4V4u7V&uw$Zjy?)%} zsm5GOLN%{sV~;GUDYTCZ(2>`2(z>k*|9(TD>gaD;HrI`Zo%H#0`ZpgFMso9LV;{vn zuSTa|uba7%XuH-+=)24Qqb<~_G`($GAz8H{e+45U9v&V#jwy{HJgpkZd7SFR^@J_2||Xp|KtG4y???RETjI7VerQIV+f zMaZpI8O3hkzkPNT#(CfjU$Zo;QQp9`8FOi8D6is=@+ z;%26E@0&oUeEbI4LW$J=d5`{x{AUa{#?NNjBD7zfXI3mn=y_Mh(N~~t>9Rq3%0D0$ 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 i9z=a@_`hO{0vJ`DX899&3* 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 diff --git a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx index 726a0d26..eccf5f7f 100644 --- a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx @@ -65,7 +65,7 @@ export const CommandTypeSelectionDialog = ({ href={hubButtonLink} target="_blank" rel="noopener noreferrer" - className="inline-block rounded-lg border px-3 pt-2 pb-1 hover:shadow-md transition" + className="inline-block rounded-lg border px-3 pt-2 pb-1 transition hover:shadow-md hover:bg-gray-50 hover:border-gray-300" > From b89b2d488ebac63f86ea2458485dd824073136c2 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 17 Aug 2026 15:28:50 +0900 Subject: [PATCH 29/37] Update: Unify font. --- .../extension/public/SelectionCommandHub.png | Bin 14573 -> 22214 bytes .../src/components/option/HubBanner.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/public/SelectionCommandHub.png b/packages/extension/public/SelectionCommandHub.png index e623cd3dd0fdfa502ad335ae3b285e753280d758..18a960410087b4b4f4c50abda279d5776c09728d 100644 GIT binary patch literal 22214 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 Selection Command Date: Mon, 17 Aug 2026 16:46:58 +0900 Subject: [PATCH 30/37] Update: Redesign HubShareToast with popper icon and title/message split, add translations HubShareToast now shows a party popper icon with popup animation and a bold title above the message, so add the hub_share_toast_title key and retranslate hub_share_toast_message across all locales. Co-Authored-By: Claude Sonnet 5 --- .../public/_locales/de/messages.json | 5 ++++- .../public/_locales/en/messages.json | 8 ++++++-- .../public/_locales/es/messages.json | 5 ++++- .../public/_locales/fr/messages.json | 5 ++++- .../public/_locales/hi/messages.json | 5 ++++- .../public/_locales/id/messages.json | 5 ++++- .../public/_locales/it/messages.json | 5 ++++- .../public/_locales/ja/messages.json | 5 ++++- .../public/_locales/ko/messages.json | 5 ++++- .../public/_locales/ms/messages.json | 5 ++++- .../public/_locales/pt_BR/messages.json | 5 ++++- .../public/_locales/pt_PT/messages.json | 5 ++++- .../public/_locales/ru/messages.json | 5 ++++- .../public/_locales/zh_CN/messages.json | 5 ++++- packages/extension/public/party_popper.png | Bin 0 -> 8277 bytes .../src/components/option/HubShareToast.tsx | 18 ++++++++++++------ packages/extension/tailwind.config.js | 11 +++++++++++ 17 files changed, 81 insertions(+), 21 deletions(-) create mode 100644 packages/extension/public/party_popper.png diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 78b12665..91a1d709 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Bewertung schreiben" }, + "hub_share_toast_title": { + "message": "Befehl erstellt!" + }, "hub_share_toast_message": { - "message": "Möchten Sie diesen Befehl mit dem Selection Command Hub teilen?" + "message": "Möchten Sie ihn mit dem Selection Command Hub teilen?" }, "hub_share_toast_button": { "message": "Teilen" diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 97100f56..8514e366 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1016,9 +1016,13 @@ "review_request_button": { "message": "Write a Review" }, + "hub_share_toast_title": { + "message": "Command created!", + "description": "Toast title shown once, right after a new command is created." + }, "hub_share_toast_message": { - "message": "Would you like to share this command with the Selection Command Hub?", - "description": "Toast message shown once, right after a new command is created, inviting the user to share it to the Hub." + "message": "Want to share it with the Selection Command Hub?", + "description": "Toast message inviting the user to share the newly created command to the Hub." }, "hub_share_toast_button": { "message": "Share", diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 1f8eff56..64e8143a 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Escribir una Reseña" }, + "hub_share_toast_title": { + "message": "¡Comando creado!" + }, "hub_share_toast_message": { - "message": "¿Quieres compartir este comando con Selection Command Hub?" + "message": "¿Quieres compartirlo con Selection Command Hub?" }, "hub_share_toast_button": { "message": "Compartir" diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 29878326..3f91714e 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Écrire un Avis" }, + "hub_share_toast_title": { + "message": "Commande créée !" + }, "hub_share_toast_message": { - "message": "Souhaitez-vous partager cette commande avec Selection Command Hub ?" + "message": "Souhaitez-vous la partager avec Selection Command Hub ?" }, "hub_share_toast_button": { "message": "Partager" diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 96278ef8..21d441da 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "समीक्षा लिखें" }, + "hub_share_toast_title": { + "message": "कमांड बन गया है!" + }, "hub_share_toast_message": { - "message": "क्या आप इस कमांड को Selection Command Hub पर साझा करना चाहेंगे?" + "message": "क्या आप इसे Selection Command Hub पर साझा करना चाहेंगे?" }, "hub_share_toast_button": { "message": "साझा करें" diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 21cd7763..a34ea6d4 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_title": { + "message": "Perintah berhasil dibuat!" + }, "hub_share_toast_message": { - "message": "Ingin membagikan perintah ini ke Selection Command Hub?" + "message": "Ingin membagikannya ke Selection Command Hub?" }, "hub_share_toast_button": { "message": "Bagikan" diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index dc68a70e..ce36201e 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Scrivi recensione" }, + "hub_share_toast_title": { + "message": "Comando creato!" + }, "hub_share_toast_message": { - "message": "Vuoi condividere questo comando con Selection Command Hub?" + "message": "Vuoi condividerlo con Selection Command Hub?" }, "hub_share_toast_button": { "message": "Condividi" diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index f3558be0..453c3517 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1002,8 +1002,11 @@ "review_request_button": { "message": "レビューする" }, + "hub_share_toast_title": { + "message": "コマンドが作成されました!" + }, "hub_share_toast_message": { - "message": "コマンドをSelection Command Hubへ共有しませんか?" + "message": "Selection Command Hubへ共有してみませんか?" }, "hub_share_toast_button": { "message": "共有する" diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 6f405c36..c6f89e08 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "리뷰 작성" }, + "hub_share_toast_title": { + "message": "명령이 생성되었습니다!" + }, "hub_share_toast_message": { - "message": "이 명령을 Selection Command Hub에 공유하시겠습니까?" + "message": "Selection Command Hub에 공유하시겠습니까?" }, "hub_share_toast_button": { "message": "공유하기" diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 45462caa..0bb6f011 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_title": { + "message": "Arahan berjaya dicipta!" + }, "hub_share_toast_message": { - "message": "Adakah anda ingin berkongsi arahan ini ke Selection Command Hub?" + "message": "Adakah anda ingin berkongsinya ke Selection Command Hub?" }, "hub_share_toast_button": { "message": "Kongsi" diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 4c8f1651..5e865323 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_title": { + "message": "Comando criado!" + }, "hub_share_toast_message": { - "message": "Deseja compartilhar este comando com o Selection Command Hub?" + "message": "Deseja compartilhá-lo com o Selection Command Hub?" }, "hub_share_toast_button": { "message": "Compartilhar" diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 98283d41..c57367cc 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_title": { + "message": "Comando criado!" + }, "hub_share_toast_message": { - "message": "Deseja partilhar este comando com o Selection Command Hub?" + "message": "Deseja partilhá-lo com o Selection Command Hub?" }, "hub_share_toast_button": { "message": "Partilhar" diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index e020a656..6c25f653 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "Написать отзыв" }, + "hub_share_toast_title": { + "message": "Команда создана!" + }, "hub_share_toast_message": { - "message": "Хотите поделиться этой командой в Selection Command Hub?" + "message": "Хотите поделиться ею в Selection Command Hub?" }, "hub_share_toast_button": { "message": "Поделиться" diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index baee2109..f9006916 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1005,8 +1005,11 @@ "review_request_button": { "message": "写评论" }, + "hub_share_toast_title": { + "message": "命令已创建!" + }, "hub_share_toast_message": { - "message": "要将此命令分享到 Selection Command Hub 吗?" + "message": "要将它分享到 Selection Command Hub 吗?" }, "hub_share_toast_button": { "message": "分享" diff --git a/packages/extension/public/party_popper.png b/packages/extension/public/party_popper.png new file mode 100644 index 0000000000000000000000000000000000000000..0e62d90c2cce6f4e13c491be40bf8ef74bc05b3c GIT binary patch literal 8277 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 TKe24Z00000NkvXXu0mjf7p(Z- literal 0 HcmV?d00001 diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx index 6387c12d..bb5f2092 100644 --- a/packages/extension/src/components/option/HubShareToast.tsx +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -41,14 +41,20 @@ function showHubShareToastNow( toast.custom( (toastId) => (

-
+
Selection Command Hub - {t("hub_share_toast_message")} +
+ + {t("hub_share_toast_title")} + + {t("hub_share_toast_message")} +