From 718b5dd0d923fadbc8c37fbf29b7fc5e0742c117 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 15:00:08 +0900 Subject: [PATCH 1/6] 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 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 2/6] 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 ffe40a0c5972577710c730f13de25e302479dba6 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 16:33:18 +0900 Subject: [PATCH 3/6] 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 4225019b3108a36aaf2f3dfd74c123638da62beb Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 8 Aug 2026 17:12:50 +0900 Subject: [PATCH 4/6] 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 5/6] 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 6/6] 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)