From e3366378044f407959222d1f79252207371dedf1 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 12:09:38 +0900 Subject: [PATCH 1/9] =?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 2/9] =?UTF-8?q?fix:=20=E3=83=84=E3=83=BC=E3=83=AB=E3=83=81?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=81=AE=E6=8A=BC=E4=B8=8B=E3=81=A7=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E5=A4=89=E6=9B=B4=E3=81=8C=E7=99=BA=E7=94=9F=E3=81=97?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=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 3/9] =?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 4/9] =?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 adf169ea2af2db183eefedcfa022516a27b56f35 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 15:24:38 +0900 Subject: [PATCH 5/9] =?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 04cae65623f97ea22564dad1e1d8a9caa10d6b90 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:18:56 +0900 Subject: [PATCH 6/9] =?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 7/9] 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:39:18 +0900 Subject: [PATCH 8/9] 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 9/9] 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")}