diff --git a/packages/extension/package.json b/packages/extension/package.json
index f5d31567..8e3fa722 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -48,7 +48,7 @@
"embla-carousel-react": "^8.6.0",
"get-xpath": "^3.3.0",
"lottie-web": "^5.12.2",
- "lucide-react": "^0.483.0",
+ "lucide-react": "^1.16.0",
"platform": "^1.3.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx
index 54d201e7..d9da856d 100644
--- a/packages/extension/src/components/option/ShareButton.tsx
+++ b/packages/extension/src/components/option/ShareButton.tsx
@@ -1,5 +1,5 @@
import { useState, useRef } from "react"
-import { Share } from "lucide-react"
+import { Share, CloudCheck } from "lucide-react"
import { Tooltip } from "@/components/Tooltip"
import { cn, isUUIDv7, generateId } from "@/lib/utils"
import { t } from "@/services/i18n"
@@ -39,7 +39,7 @@ export const ShareButton = ({
if (isShared) {
// Open the hub dashboard page for the shared command
const locale = getHubLocale()
- const url = `${NEW_HUB_URL}/${locale}/dashboard/commands?id=${encodeURIComponent(command.id)}`
+ const url = `${NEW_HUB_URL}/${locale}/dashboard/mycommands?id=${encodeURIComponent(command.id)}`
chrome.tabs.create({ url })
return
}
@@ -71,19 +71,27 @@ export const ShareButton = ({
className={cn(
"outline-gray-200 p-2 rounded-md transition hover:bg-green-100 hover:scale-125 group/share-btn",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none",
- isShared && "bg-green-50",
)}
onClick={handleClick}
ref={buttonRef}
>
-
+ {!isShared ? (
+
+ ) : (
+
+ )}
{
const sharedCommandIds = useSharedCommandIds()
- const { getValues, setValue } = useFormContext()
-
const commandArray = useFieldArray({
name: "commands",
control: control,
@@ -306,22 +300,9 @@ export const CommandList = ({ control }: CommandListProps) => {
}
const handleUpdateCommandId = (commandId: string, newId: string) => {
- // Update command ID
- const idx = commandArray.fields.findIndex((f) => f.id === commandId)
- if (idx >= 0) {
- commandArray.update(idx, { ...commandArray.fields[idx], id: newId })
- }
- // Update any shortcuts that reference this command ID
- const currentShortcuts: ShortcutCommand[] =
- getValues("shortcuts.shortcuts") ?? []
- if (currentShortcuts.some((s) => s.commandId === commandId)) {
- setValue(
- "shortcuts.shortcuts",
- currentShortcuts.map((s) =>
- s.commandId === commandId ? { ...s, commandId: newId } : s,
- ),
- )
- }
+ Settings.updateCommandId(commandId, newId).catch((err) => {
+ console.error("[handleUpdateCommandId] Failed to update command ID:", err)
+ })
}
const commandRemove = (idx: number) => {
diff --git a/packages/extension/src/components/option/editor/ShortcutList.tsx b/packages/extension/src/components/option/editor/ShortcutList.tsx
index 438c8623..2fef9bb0 100644
--- a/packages/extension/src/components/option/editor/ShortcutList.tsx
+++ b/packages/extension/src/components/option/editor/ShortcutList.tsx
@@ -1,10 +1,5 @@
import { useEffect, useState, useMemo } from "react"
-import {
- Control,
- useFieldArray,
- useWatch,
- useFormContext,
-} from "react-hook-form"
+import { useFieldArray, useWatch, useFormContext } from "react-hook-form"
import { Keyboard, SquareArrowOutUpRight } from "lucide-react"
import { SelectField } from "@/components/option/field/SelectField"
import type { SelectOptionType } from "@/components/option/field/SelectField"
@@ -30,7 +25,7 @@ import { INSERT, toInsertTemplate } from "@/services/pageAction"
const t = (key: string, p?: string[]) => _t(`Option_${key}`, p)
type ShortcutListProps = {
- control: Control
+ control: any
}
const isTextSelectionOnly = (command: Command) => {
diff --git a/packages/extension/src/hooks/option/useSharedCommandIds.ts b/packages/extension/src/hooks/option/useSharedCommandIds.ts
index 15a6e43e..c6725dbc 100644
--- a/packages/extension/src/hooks/option/useSharedCommandIds.ts
+++ b/packages/extension/src/hooks/option/useSharedCommandIds.ts
@@ -13,8 +13,10 @@ export function useSharedCommandIds(): Set {
useEffect(() => {
let cancelled = false
+ let currentHubUser: HubUser | null = null
const fetchIds = async (hubUser: HubUser | null) => {
+ currentHubUser = hubUser
if (cancelled) return
if (!hubUser) {
setSharedIds(new Set())
@@ -35,9 +37,16 @@ export function useSharedCommandIds(): Set {
(newVal) => fetchIds(newVal),
)
+ // Re-fetch after a command is successfully shared to the hub
+ const unsubscribeSharedAt = Storage.addListener(
+ LOCAL_STORAGE_KEY.HUB_SHARED_AT,
+ () => fetchIds(currentHubUser),
+ )
+
return () => {
cancelled = true
unsubscribe()
+ unsubscribeSharedAt()
}
}, [])
diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts
index a2367bc3..be8e6da1 100644
--- a/packages/extension/src/services/hub/background.test.ts
+++ b/packages/extension/src/services/hub/background.test.ts
@@ -23,11 +23,11 @@ vi.mock("@/services/storage", () => ({
set: vi.fn(),
updateCommands: vi.fn(),
},
- LOCAL_STORAGE_KEY: { HUB_USER: "hubUser" },
+ LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", HUB_SHARED_AT: "hubSharedAt" },
}))
vi.mock("@/services/settings/settings", () => ({
- Settings: { addCommands: vi.fn() },
+ Settings: { addCommands: vi.fn(), updateCommandId: vi.fn() },
}))
vi.mock("@/services/analytics", () => ({
@@ -120,6 +120,7 @@ beforeEach(() => {
resetEditSession()
vi.mocked(Storage.updateCommands).mockResolvedValue(true)
vi.mocked(Settings.addCommands).mockResolvedValue(true)
+ vi.mocked(Settings.updateCommandId).mockResolvedValue(undefined)
vi.mocked(Storage.setCommands).mockResolvedValue(true)
vi.mocked(Storage.set).mockResolvedValue(true)
vi.mocked(sendEvent).mockResolvedValue(undefined as any)
@@ -1003,7 +1004,7 @@ describe("shareCommandToHub", () => {
expect(mockPort.postMessage).not.toHaveBeenCalled()
})
- it("SH-07: valid port sends share-command and removes listener on ack", async () => {
+ it("SH-07: valid port sends share-command; ack stops timer but keeps listener", async () => {
vi.useFakeTimers()
vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => {
cb?.({ id: 42 } as chrome.tabs.Tab)
@@ -1040,7 +1041,153 @@ describe("shareCommandToHub", () => {
})
expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled()
+ // ack stops the retry timer but keeps the message listener alive
capturedOnMessage?.({ type: "share-command-ack" })
+ expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
+
+ it("SH-08: share-command-submitted updates HUB_SHARED_AT and removes listener", async () => {
+ vi.useFakeTimers()
+ 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 Promise.resolve()
+
+ const portConnectListener = vi.mocked(
+ chrome.runtime.onConnectExternal.addListener,
+ ).mock.calls[0][0]
+
+ let capturedOnMessage: ((msg: unknown) => void) | undefined
+ const mockPort = {
+ name: "hub-share",
+ sender: { tab: { id: 42 } },
+ postMessage: vi.fn(),
+ onMessage: {
+ addListener: vi.fn((fn) => {
+ capturedOnMessage = fn
+ }),
+ removeListener: vi.fn(),
+ },
+ }
+ portConnectListener(mockPort as any)
+
+ vi.advanceTimersByTime(100)
+
+ capturedOnMessage?.({ type: "share-command-ack" })
+ expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled()
+ expect(Storage.set).not.toHaveBeenCalledWith(
+ LOCAL_STORAGE_KEY.HUB_SHARED_AT,
+ expect.any(Number),
+ )
+
+ capturedOnMessage?.({ type: "share-command-submitted", commandId: "cmd-1" })
+ expect(mockPort.onMessage.removeListener).toHaveBeenCalled()
+ await vi.waitFor(() =>
+ expect(Storage.set).toHaveBeenCalledWith(
+ LOCAL_STORAGE_KEY.HUB_SHARED_AT,
+ expect.any(Number),
+ ),
+ )
+
+ vi.useRealTimers()
+ })
+
+ it("SH-09: DUPLICATE_COMMAND_ID awaits updateCommandId before re-sending", async () => {
+ vi.useFakeTimers()
+ 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 Promise.resolve()
+
+ const portConnectListener = vi.mocked(
+ chrome.runtime.onConnectExternal.addListener,
+ ).mock.calls[0][0]
+
+ let capturedOnMessage: ((msg: unknown) => Promise) | undefined
+ const mockPort = {
+ name: "hub-share",
+ sender: { tab: { id: 42 } },
+ postMessage: vi.fn(),
+ onMessage: {
+ addListener: vi.fn((fn) => {
+ capturedOnMessage = fn
+ }),
+ removeListener: vi.fn(),
+ },
+ }
+ portConnectListener(mockPort as any)
+ vi.advanceTimersByTime(100)
+
+ // Simulate DUPLICATE_COMMAND_ID ack
+ await capturedOnMessage?.({
+ type: "share-command-ack",
+ errorCode: "DUPLICATE_COMMAND_ID",
+ })
+
+ expect(Settings.updateCommandId).toHaveBeenCalledTimes(1)
+ // postMessage for retry must happen after updateCommandId resolves
+ expect(mockPort.postMessage).toHaveBeenLastCalledWith({
+ type: "share-command",
+ command: expect.objectContaining({
+ id: expect.not.stringMatching(param.id),
+ }),
+ })
+ expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
+
+ it("SH-10: DUPLICATE_COMMAND_ID stops share if updateCommandId fails", async () => {
+ vi.useFakeTimers()
+ vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => {
+ cb?.({ id: 42 } as chrome.tabs.Tab)
+ return Promise.resolve({ id: 42 } as chrome.tabs.Tab)
+ })
+ vi.mocked(Settings.updateCommandId).mockRejectedValue(
+ new Error("update failed"),
+ )
+ const response = vi.fn()
+ shareCommandToHub(param, sender, response)
+
+ await Promise.resolve()
+
+ const portConnectListener = vi.mocked(
+ chrome.runtime.onConnectExternal.addListener,
+ ).mock.calls[0][0]
+
+ let capturedOnMessage: ((msg: unknown) => Promise) | undefined
+ const mockPort = {
+ name: "hub-share",
+ sender: { tab: { id: 42 } },
+ postMessage: vi.fn(),
+ onMessage: {
+ addListener: vi.fn((fn) => {
+ capturedOnMessage = fn
+ }),
+ removeListener: vi.fn(),
+ },
+ }
+ portConnectListener(mockPort as any)
+ vi.advanceTimersByTime(100)
+
+ // Simulate DUPLICATE_COMMAND_ID ack with updateCommandId failure
+ await capturedOnMessage?.({
+ type: "share-command-ack",
+ errorCode: "DUPLICATE_COMMAND_ID",
+ })
+
+ // Must not send new command since local storage update failed
+ expect(mockPort.postMessage).toHaveBeenCalledTimes(1) // only the initial retry send
expect(mockPort.onMessage.removeListener).toHaveBeenCalled()
vi.useRealTimers()
@@ -1080,7 +1227,7 @@ describe("pushEditToHub", () => {
expect(chrome.tabs.create).toHaveBeenCalledWith(
{
url: expect.stringContaining(
- "hub.example.com/en/dashboard/commands?id=cmd-1",
+ "hub.example.com/en/dashboard/mycommands?id=cmd-1",
),
},
expect.any(Function),
diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts
index c934a715..0b8c5885 100644
--- a/packages/extension/src/services/hub/background.ts
+++ b/packages/extension/src/services/hub/background.ts
@@ -18,6 +18,7 @@ import {
} from "@/services/analytics"
import { PopupOption } from "@/services/option/defaultSettings"
import {
+ generateId,
isSearchCommand,
isPageActionCommand,
isAiPromptCommand,
@@ -78,6 +79,10 @@ export const shareCommandToHub = (
const share = async () => {
try {
+ let currentParam = param
+ let idRegenerateCount = 0
+ const MAX_ID_REGENERATE = 3
+
// Use a named function expression so the handler can remove itself via
// `portConnect` (inner self-reference, always valid inside the handler).
// The outer `onPortConnect` variable is used for cleanup in error paths
@@ -93,9 +98,59 @@ export const shareCommandToHub = (
port.onMessage.removeListener(onMessage)
}
- const onMessage = (msg: unknown) => {
- if ((msg as { type?: string })?.type === "share-command-ack") {
- cleanup()
+ const onMessage = async (msg: unknown) => {
+ const type = (msg as { type?: string })?.type
+
+ if (type === "share-command-ack") {
+ const ack = msg as { type: string; errorCode?: string }
+
+ if (
+ ack.errorCode === "DUPLICATE_COMMAND_ID" &&
+ idRegenerateCount < MAX_ID_REGENERATE
+ ) {
+ idRegenerateCount++
+ clearInterval(timer)
+
+ const oldId = currentParam.id
+ const newId = generateId()
+
+ try {
+ await Settings.updateCommandId(oldId, newId)
+ } catch (err) {
+ console.error(
+ "[shareCommandToHub] Failed to update command ID:",
+ err,
+ )
+ port.onMessage.removeListener(onMessage)
+ return
+ }
+
+ currentParam = { ...currentParam, id: newId }
+ port.postMessage({ type: "share-command", command: currentParam })
+ return
+ }
+
+ // Stop retry timer now that ack is received
+ clearInterval(timer)
+
+ if (ack.errorCode) {
+ // Error: stop listening
+ port.onMessage.removeListener(onMessage)
+ }
+ // Success: keep listener to await share-command-submitted
+ return
+ }
+
+ if (type === "share-command-submitted") {
+ port.onMessage.removeListener(onMessage)
+ Storage.set(LOCAL_STORAGE_KEY.HUB_SHARED_AT, Date.now()).catch(
+ (err) => {
+ console.error(
+ "[shareCommandToHub] Failed to update hubSharedAt:",
+ err,
+ )
+ },
+ )
}
}
port.onMessage.addListener(onMessage)
@@ -110,7 +165,7 @@ export const shareCommandToHub = (
)
return
}
- port.postMessage({ type: "share-command", command: param })
+ port.postMessage({ type: "share-command", command: currentParam })
}, RETRY_INTERVAL_MS)
}
// Register listener before tab creation so the Hub page can connect immediately on load
@@ -605,7 +660,7 @@ export const pushEditToHub = (
const tab = await new Promise((resolve) =>
chrome.tabs.create(
{
- url: `${NEW_HUB_URL}/${param.locale}/dashboard/commands?id=${encodeURIComponent(param.id)}`,
+ url: `${NEW_HUB_URL}/${param.locale}/dashboard/mycommands?id=${encodeURIComponent(param.id)}`,
},
resolve,
),
diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts
index 3d30e625..eb7afdc3 100644
--- a/packages/extension/src/services/settings/settings.ts
+++ b/packages/extension/src/services/settings/settings.ts
@@ -186,6 +186,20 @@ export const Settings = {
return true
},
+ updateCommandId: async (oldId: string, newId: string): Promise => {
+ const commands = await Storage.getCommands()
+ await Storage.setCommands(
+ commands.map((c) => (c.id === oldId ? { ...c, id: newId } : c)),
+ )
+ const shortcuts = await Storage.get(STORAGE_KEY.SHORTCUTS)
+ await Storage.set(STORAGE_KEY.SHORTCUTS, {
+ ...shortcuts,
+ shortcuts: (shortcuts?.shortcuts ?? []).map((s) =>
+ s.commandId === oldId ? { ...s, commandId: newId } : s,
+ ),
+ })
+ },
+
reset: async () => {
await Storage.set(STORAGE_KEY.USER, DefaultSettings)
await Storage.setCommands(getDefaultCommands(getUILanguage()))
@@ -305,8 +319,7 @@ const migrate0_11_5 = (data: SettingsType): SettingsType => {
data.commands = data.commands.map((c) => {
if (c.id.length === 36) return c
c.id =
- DefaultCommands.find((dc) => dc.title === c.title)?.id ??
- generateId()
+ DefaultCommands.find((dc) => dc.title === c.title)?.id ?? generateId()
return c
})
diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts
index 767326e2..e449e871 100644
--- a/packages/extension/src/services/storage/const.ts
+++ b/packages/extension/src/services/storage/const.ts
@@ -17,6 +17,7 @@ export enum LOCAL_STORAGE_KEY {
DAILY_COMMANDS_BACKUP = "dailyCommandsBackup",
WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup",
HUB_USER = "hubUser",
+ HUB_SHARED_AT = "hubSharedAt",
}
export enum SESSION_STORAGE_KEY {
diff --git a/packages/hub/eslint.config.mjs b/packages/hub/eslint.config.mjs
index 60e186d6..f39a3003 100644
--- a/packages/hub/eslint.config.mjs
+++ b/packages/hub/eslint.config.mjs
@@ -5,7 +5,13 @@ import tseslint from "typescript-eslint"
export default tseslint.config(
...rootConfig,
{
- ignores: [".next/**", "out/**", "scripts/**", "*.config.*"],
+ ignores: [
+ ".next/**",
+ "out/**",
+ "scripts/**",
+ "*.config.*",
+ "next-env.d.ts",
+ ],
},
{
files: ["**/*.{ts,tsx,js,jsx}"],
diff --git a/yarn.lock b/yarn.lock
index 27d31c30..66695f3e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5536,10 +5536,10 @@ lucide-react@^0.468.0:
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.468.0.tgz#830c1bfd905575ddd23b832baa420c87db166910"
integrity sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==
-lucide-react@^0.483.0:
- version "0.483.0"
- resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.483.0.tgz#036c538173a8c310ac6b7d6af150543e5ecdf116"
- integrity sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==
+lucide-react@^1.16.0:
+ version "1.16.0"
+ resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-1.16.0.tgz#8c19a757982ed28e28afd0da9a950d3152e4b7e5"
+ integrity sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==
lz-string@^1.5.0:
version "1.5.0"