diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d9717718..e3ed0a5b 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -70,7 +70,9 @@
"mcp__serena__think_about_collected_information",
"mcp__serena__think_about_task_adherence",
"mcp__serena__think_about_whether_you_are_done",
- "mcp__serena__write_memory"
+ "mcp__serena__write_memory",
+ "Bash(npx vitest *)",
+ "Bash(pnpm --filter extension tsc --noEmit)"
],
"deny": []
}
diff --git a/.serena/project.yml b/.serena/project.yml
index b40c0729..f0fbf17e 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -1,10 +1,9 @@
-# whether to use the project's gitignore file to ignore files
-# Added on 2025-04-07
+# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
-# list of additional paths to ignore
-# same syntax as gitignore, so you can use * and **
-# Was previously called `ignored_dirs`, please update your config if you are using that.
-# Added (renamed)on 2025-04-07
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
@@ -12,45 +11,9 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -67,18 +30,24 @@ project_name: "selection-command"
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# the encoding used by text files in the project
@@ -86,22 +55,33 @@ fixed_tools: []
encoding: utf-8
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# php_phpactor powershell python python_jedi r
-# rego ruby ruby_solargraph rust scala
-# swift terraform toml typescript typescript_vts
-# vue yaml zig
-# powershell python python_jedi r rego
-# ruby ruby_solargraph rust scala swift
-# terraform toml typescript typescript_vts vue
-# yaml zig
-#
+# al ansible bash clojure cpp
+# cpp_ccls crystal csharp csharp_omnisharp dart
+# elixir elm erlang fortran fsharp
+# go groovy haskell haxe hlsl
+# java json julia kotlin lean4
+# lua luau markdown matlab msl
+# nix ocaml pascal perl php
+# php_phpactor powershell python python_jedi python_ty
+# r rego ruby ruby_solargraph rust
+# scala solidity swift systemverilog terraform
+# toml typescript typescript_vts vue yaml
+# zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
-- typescript
+ - typescript
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
@@ -138,3 +118,8 @@ ignored_memory_patterns: []
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json
index 8d88d6d1..9f7c9a61 100644
--- a/packages/extension/public/_locales/de/messages.json
+++ b/packages/extension/public/_locales/de/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Kann auf dieser Seite nicht ausgeführt werden (Seiten-URL stimmt nicht überein)"
- },
- "commandHub_add_success": {
- "message": "Befehl hinzugefügt!"
- },
- "commandHub_add_error": {
- "message": "Fehler beim Hinzufügen des Befehls."
- },
- "commandHub_delete_success": {
- "message": "Befehl gelöscht."
- },
- "commandHub_delete_error": {
- "message": "Fehler beim Löschen des Befehls."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json
index 2245b928..b6a0352f 100644
--- a/packages/extension/public/_locales/en/messages.json
+++ b/packages/extension/public/_locales/en/messages.json
@@ -1253,17 +1253,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Cannot run on this page (page URL does not match)"
- },
- "commandHub_add_success": {
- "message": "Command added!"
- },
- "commandHub_add_error": {
- "message": "Failed to add command."
- },
- "commandHub_delete_success": {
- "message": "Command deleted."
- },
- "commandHub_delete_error": {
- "message": "Failed to delete command."
}
}
diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json
index 40fa7b2c..7aadf686 100644
--- a/packages/extension/public/_locales/es/messages.json
+++ b/packages/extension/public/_locales/es/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "No se puede ejecutar en esta página (la URL de la página no coincide)"
- },
- "commandHub_add_success": {
- "message": "¡Comando añadido!"
- },
- "commandHub_add_error": {
- "message": "Error al añadir el comando."
- },
- "commandHub_delete_success": {
- "message": "Comando eliminado."
- },
- "commandHub_delete_error": {
- "message": "Error al eliminar el comando."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json
index 8cb7f21c..ca4c32f1 100644
--- a/packages/extension/public/_locales/fr/messages.json
+++ b/packages/extension/public/_locales/fr/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Impossible d'exécuter sur cette page (l'URL de la page ne correspond pas)"
- },
- "commandHub_add_success": {
- "message": "Commande ajoutée !"
- },
- "commandHub_add_error": {
- "message": "Échec de l'ajout de la commande."
- },
- "commandHub_delete_success": {
- "message": "Commande supprimée."
- },
- "commandHub_delete_error": {
- "message": "Échec de la suppression de la commande."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json
index c12baec9..abd92477 100644
--- a/packages/extension/public/_locales/hi/messages.json
+++ b/packages/extension/public/_locales/hi/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "इस पेज पर नहीं चला सकते (पृष्ठ URL मेल नहीं खाता)"
- },
- "commandHub_add_success": {
- "message": "कमांड जोड़ा गया!"
- },
- "commandHub_add_error": {
- "message": "कमांड जोड़ने में विफल।"
- },
- "commandHub_delete_success": {
- "message": "कमांड हटाया गया।"
- },
- "commandHub_delete_error": {
- "message": "कमांड हटाने में विफल।"
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json
index 0fc81d1c..effb3a26 100644
--- a/packages/extension/public/_locales/id/messages.json
+++ b/packages/extension/public/_locales/id/messages.json
@@ -1250,17 +1250,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Tidak dapat dijalankan di halaman ini (URL Halaman tidak cocok)"
- },
- "commandHub_add_success": {
- "message": "Perintah ditambahkan!"
- },
- "commandHub_add_error": {
- "message": "Gagal menambahkan perintah."
- },
- "commandHub_delete_success": {
- "message": "Perintah dihapus."
- },
- "commandHub_delete_error": {
- "message": "Gagal menghapus perintah."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json
index 4a81f221..ca533b2a 100644
--- a/packages/extension/public/_locales/it/messages.json
+++ b/packages/extension/public/_locales/it/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Impossibile eseguire su questa pagina (l'URL della pagina non corrisponde)"
- },
- "commandHub_add_success": {
- "message": "Comando aggiunto!"
- },
- "commandHub_add_error": {
- "message": "Impossibile aggiungere il comando."
- },
- "commandHub_delete_success": {
- "message": "Comando eliminato."
- },
- "commandHub_delete_error": {
- "message": "Impossibile eliminare il comando."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json
index 0a85400d..3ac84d7e 100644
--- a/packages/extension/public/_locales/ja/messages.json
+++ b/packages/extension/public/_locales/ja/messages.json
@@ -1244,17 +1244,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "このページでは実行できません(ページURLが一致しません)"
- },
- "commandHub_add_success": {
- "message": "コマンドを追加しました!"
- },
- "commandHub_add_error": {
- "message": "コマンドの追加に失敗しました。"
- },
- "commandHub_delete_success": {
- "message": "コマンドを削除しました。"
- },
- "commandHub_delete_error": {
- "message": "コマンドの削除に失敗しました。"
}
}
diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json
index ff0d2e5d..75c61c9f 100644
--- a/packages/extension/public/_locales/ko/messages.json
+++ b/packages/extension/public/_locales/ko/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "이 페이지에서 실행할 수 없습니다 (페이지 URL이 일치하지 않습니다)"
- },
- "commandHub_add_success": {
- "message": "명령이 추가되었습니다!"
- },
- "commandHub_add_error": {
- "message": "명령 추가에 실패했습니다."
- },
- "commandHub_delete_success": {
- "message": "명령이 삭제되었습니다."
- },
- "commandHub_delete_error": {
- "message": "명령 삭제에 실패했습니다."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json
index 831d3d13..dcaefb80 100644
--- a/packages/extension/public/_locales/ms/messages.json
+++ b/packages/extension/public/_locales/ms/messages.json
@@ -1250,17 +1250,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Tidak boleh dijalankan pada halaman ini (URL Halaman tidak sepadan)"
- },
- "commandHub_add_success": {
- "message": "Arahan ditambahkan!"
- },
- "commandHub_add_error": {
- "message": "Gagal menambah arahan."
- },
- "commandHub_delete_success": {
- "message": "Arahan dipadam."
- },
- "commandHub_delete_error": {
- "message": "Gagal memadam arahan."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json
index e5767ffc..63f2af3b 100644
--- a/packages/extension/public/_locales/pt_BR/messages.json
+++ b/packages/extension/public/_locales/pt_BR/messages.json
@@ -1250,17 +1250,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Não é possível executar nesta página (URL da página não corresponde)"
- },
- "commandHub_add_success": {
- "message": "Comando adicionado!"
- },
- "commandHub_add_error": {
- "message": "Falha ao adicionar o comando."
- },
- "commandHub_delete_success": {
- "message": "Comando excluído."
- },
- "commandHub_delete_error": {
- "message": "Falha ao excluir o comando."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json
index a02df32b..419f1a3e 100644
--- a/packages/extension/public/_locales/pt_PT/messages.json
+++ b/packages/extension/public/_locales/pt_PT/messages.json
@@ -1250,17 +1250,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Não é possível executar nesta página (URL da página não corresponde)"
- },
- "commandHub_add_success": {
- "message": "Comando adicionado!"
- },
- "commandHub_add_error": {
- "message": "Falha ao adicionar o comando."
- },
- "commandHub_delete_success": {
- "message": "Comando eliminado."
- },
- "commandHub_delete_error": {
- "message": "Falha ao eliminar o comando."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json
index caac6a56..31c98caa 100644
--- a/packages/extension/public/_locales/ru/messages.json
+++ b/packages/extension/public/_locales/ru/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "Невозможно выполнить на этой странице (URL страницы не совпадает)"
- },
- "commandHub_add_success": {
- "message": "Команда добавлена!"
- },
- "commandHub_add_error": {
- "message": "Не удалось добавить команду."
- },
- "commandHub_delete_success": {
- "message": "Команда удалена."
- },
- "commandHub_delete_error": {
- "message": "Не удалось удалить команду."
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json
index 829d0086..8adb75f2 100644
--- a/packages/extension/public/_locales/zh_CN/messages.json
+++ b/packages/extension/public/_locales/zh_CN/messages.json
@@ -1247,17 +1247,5 @@
},
"Menu_disabled_urlNotMatch": {
"message": "无法在此页面运行(页面URL不匹配)"
- },
- "commandHub_add_success": {
- "message": "命令已添加!"
- },
- "commandHub_add_error": {
- "message": "添加命令失败。"
- },
- "commandHub_delete_success": {
- "message": "命令已删除。"
- },
- "commandHub_delete_error": {
- "message": "删除命令失败。"
}
-}
\ No newline at end of file
+}
diff --git a/packages/extension/src/components/commandHub/CommandHub.tsx b/packages/extension/src/components/commandHub/CommandHub.tsx
index 0fcbbd2b..b730d975 100644
--- a/packages/extension/src/components/commandHub/CommandHub.tsx
+++ b/packages/extension/src/components/commandHub/CommandHub.tsx
@@ -1,11 +1,6 @@
-import { DownloadButton } from "@/components/commandHub/DownloadButton"
-import { StarButton } from "@/components/commandHub/StarButton"
+import { useCommandHubBridge } from "@/hooks/useCommandHubBridge"
export const CommandHub = (): JSX.Element => {
- return (
- <>
-
-
- >
- )
+ useCommandHubBridge()
+ return <>>
}
diff --git a/packages/extension/src/components/commandHub/DownloadButton.tsx b/packages/extension/src/components/commandHub/DownloadButton.tsx
deleted file mode 100644
index f9561ac2..00000000
--- a/packages/extension/src/components/commandHub/DownloadButton.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import { useState, useEffect } from "react"
-import clsx from "clsx"
-import { toast, Toaster } from "sonner"
-import { Ipc, BgCommand } from "@/services/ipc"
-import { useSection } from "@/hooks/useSettings"
-import { useDetectUrlChanged } from "@/hooks/useDetectUrlChanged"
-import { CACHE_SECTIONS } from "@/services/settings/settingsCache"
-import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics"
-import {
- Popover,
- PopoverContent,
- PopoverAnchor,
- PopoverArrow,
-} from "@/components/ui/popover"
-import { SCREEN, HUB_URL } from "@/const"
-import { t } from "@/services/i18n"
-
-const TooltipDuration = 2000
-
-export const DownloadButton = (): JSX.Element => {
- const [position, setPosition] = useState(null)
- const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS)
- const { addUrlChangeListener, removeUrlChangeListener } =
- useDetectUrlChanged()
- const [shouldRender, setShouldRender] = useState(false)
- const open = position != null
-
- const setButtonClickListener = () => {
- document.querySelectorAll("button[data-command]").forEach((button) => {
- if (!(button instanceof HTMLButtonElement)) return
- const command = button.dataset.command
- const id = button.dataset.id
- if (command == null) return
- button.dataset.clickable = "true"
-
- // Deprecated:
- // We will remove this in the future.
- // Please use postMessage to communicate with the content script.
- button.addEventListener("click", () => {
- Ipc.send(BgCommand.addCommand, { command }).then((res) => {
- if (res) {
- sendEvent(
- ANALYTICS_EVENTS.COMMAND_HUB_ADD,
- { id },
- SCREEN.COMMAND_HUB,
- )
- setPosition(button.parentElement)
- }
- })
- })
- })
- }
-
- const updateButtonVisibility = () => {
- const ids = commands?.map((c) => c.id) ?? []
- ids.forEach((id) => {
- // hide installed buttons
- const installed = document.querySelector(
- `button[data-id="${id}"]`,
- ) as HTMLElement
- if (installed) installed.style.display = "none"
- // show installed label
- const p = document.querySelector(`p[data-id="${id}"]`) as HTMLElement
- if (p) p.style.display = "block"
- })
- }
-
- const updateCount = () => {
- document.querySelectorAll("span[data-id]").forEach((span) => {
- if (!(span instanceof HTMLElement)) return
- const count = Number(span.dataset.downloadCount)
- if (count == null || isNaN(count)) return
- let reviced = 0
- const cmd = commands?.find((c) => c.id === span.dataset.id)
- if (cmd != null) {
- // There is a command.
- reviced++
- }
- span.textContent = (count + reviced).toLocaleString()
- })
- }
-
- useEffect(() => {
- setButtonClickListener()
- updateButtonVisibility()
- addUrlChangeListener(setButtonClickListener)
- addUrlChangeListener(updateButtonVisibility)
- return () => {
- removeUrlChangeListener(setButtonClickListener)
- }
- }, [])
-
- useEffect(() => {
- updateButtonVisibility()
- updateCount()
- addUrlChangeListener(updateButtonVisibility)
- addUrlChangeListener(updateCount)
- return () => {
- removeUrlChangeListener(updateButtonVisibility)
- removeUrlChangeListener(updateCount)
- }
- }, [commands])
-
- useEffect(() => {
- if (!open) return
- const timer = setTimeout(() => setPosition(null), TooltipDuration)
- return () => {
- clearTimeout(timer)
- }
- }, [open])
-
- useEffect(() => {
- let timer: NodeJS.Timeout
- if (open) {
- timer = setTimeout(() => {
- setShouldRender(true)
- }, 100)
- } else {
- setShouldRender(false)
- }
- return () => clearTimeout(timer)
- }, [open])
-
- /**
- * External postMessage API for adding/deleting commands from the Hub.
- *
- * This content script listens for messages from the Hub page (origin must match HUB_URL).
- * The message object must have the following shape:
- *
- * --- AddCommand ---
- * {
- * action: "AddCommand",
- * command: string // JSON-stringified command object (see below)
- * }
- *
- * The `command` field is a JSON string representing a SearchCommand, an AiPromptCommand, or a PageActionCommand.
- *
- * SearchCommand (openMode is one of "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"):
- * {
- * id: string, // Unique command identifier
- * title: string, // Display name of the command
- * searchUrl: string, // Search URL template (%s is replaced with selected text)
- * iconUrl: string, // URL of the command icon
- * openMode: string, // How to open the result: "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"
- * openModeSecondary?: string, // Secondary open mode (optional)
- * spaceEncoding?: string, // Space encoding in URL: "plus" | "percent" (optional)
- * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
- * sourceId?: string // Identifier of the source (optional)
- * }
- *
- * AiPromptCommand (openMode is "aiPrompt"):
- * {
- * id: string, // Unique command identifier
- * title: string, // Display name of the command
- * iconUrl: string, // URL of the command icon
- * openMode: "aiPrompt", // Must be "aiPrompt" for AI prompt commands
- * aiPromptOption: {
- * serviceId: string, // ID of the AI service to use (see hub/public/data/ai-services.json)
- * prompt: string, // Prompt text sent to the AI service (supports variable placeholders)
- * openMode: string // How to open the AI service result: "popup" | "tab" | "window" | etc.
- * },
- * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
- * sourceId?: string // Identifier of the source (optional)
- * }
- *
- * PageActionCommand (openMode is "pageAction"):
- * {
- * id: string, // Unique command identifier
- * title: string, // Display name of the command
- * iconUrl: string, // URL of the command icon
- * openMode: "pageAction", // Must be "pageAction" for page action commands
- * pageActionOption: {
- * startUrl: string, // URL to open when executing the page action
- * pageUrl?: string, // URL pattern for command enablement (currentTab mode only, optional)
- * openMode: string, // How to open the page: "none" | "popup" | "tab" | "backgroundTab" | "window" | "currentTab"
- * steps: Array, // Sequence of automation steps to execute
- * userVariables?: Array<{ name: string, value: string }> // User-defined variables (optional)
- * },
- * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
- * sourceId?: string // Identifier of the source (optional)
- * }
- *
- * --- DeleteCommand ---
- * {
- * action: "DeleteCommand",
- * id: string // ID of the command to remove
- * }
- */
- useEffect(() => {
- const hubOrigin = new URL(HUB_URL).origin
- const handleMessage = (event: MessageEvent) => {
- if (event.origin !== hubOrigin) return
- const { action, command, id } = event.data ?? {}
- if (action === "AddCommand") {
- if (typeof command !== "string") return
- Ipc.send(BgCommand.addCommand, { command })
- .then((res) => {
- if (res) {
- toast.success(t("commandHub_add_success"))
- } else {
- toast.error(t("commandHub_add_error"))
- }
- })
- .catch(() => {
- toast.error(t("commandHub_add_error"))
- })
- } else if (action === "DeleteCommand") {
- if (typeof id !== "string") return
- Ipc.send(BgCommand.removeCommand, { id })
- .then((res) => {
- if (res) {
- toast.success(t("commandHub_delete_success"))
- } else {
- toast.error(t("commandHub_delete_error"))
- }
- })
- .catch(() => {
- toast.error(t("commandHub_delete_error"))
- })
- }
- }
- window.addEventListener("message", handleMessage)
- return () => {
- window.removeEventListener("message", handleMessage)
- }
- }, [])
-
- return (
- <>
-
-
-
- {shouldRender && (
-
- Command added!
-
-
- )}
-
- >
- )
-}
diff --git a/packages/extension/src/components/commandHub/StarButton.tsx b/packages/extension/src/components/commandHub/StarButton.tsx
deleted file mode 100644
index 3f172c25..00000000
--- a/packages/extension/src/components/commandHub/StarButton.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useEffect, useCallback } from "react"
-import { useSection } from "@/hooks/useSettings"
-import { CACHE_SECTIONS } from "@/services/settings/settingsCache"
-import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics"
-import { SCREEN } from "@/const"
-import { useDetectUrlChanged } from "@/hooks/useDetectUrlChanged"
-import { Ipc, BgCommand } from "@/services/ipc"
-
-function isButtonElement(elm: Element): elm is HTMLButtonElement {
- return elm.tagName?.toLowerCase() === "button"
-}
-
-function findButtonElement(elm: Element): HTMLButtonElement | undefined {
- if (elm == null) return undefined
- if (elm.nodeName === "body") return undefined
- if (isButtonElement(elm)) return elm
- return findButtonElement(elm.parentElement as Element)
-}
-
-export const StarButton = (): JSX.Element => {
- const { data: stars } = useSection(CACHE_SECTIONS.STARS)
- const { addUrlChangeListener, removeUrlChangeListener } =
- useDetectUrlChanged()
-
- const updateStar = useCallback(
- (e: MouseEvent) => {
- const button = findButtonElement(e.target as Element)
- const id = button?.dataset.starId
- if (id == null) return
- const found = stars?.some((s) => s.id === id) ?? false
- sendEvent(
- found
- ? ANALYTICS_EVENTS.COMMAND_HUB_STAR_REMOVE
- : ANALYTICS_EVENTS.COMMAND_HUB_STAR_ADD,
- { id },
- SCREEN.COMMAND_HUB,
- )
- Ipc.send(BgCommand.toggleStar, { id })
- },
- [stars],
- )
-
- const updateButton = useCallback(() => {
- document.querySelectorAll("button[data-star-id]").forEach((button) => {
- if (!(button instanceof HTMLButtonElement)) return
- const id = button.dataset.starId
- if (id == null) return
- button.addEventListener("click", updateStar)
- button.dataset.clickable = "true"
- if (stars?.some((s) => s.id === id)) {
- button.dataset.starred = "true"
- } else {
- button.dataset.starred = "false"
- }
- })
- }, [stars, updateStar])
-
- const updateCount = useCallback(() => {
- document.querySelectorAll("span[data-star-id]").forEach((span) => {
- if (!(span instanceof HTMLElement)) return
- const count = Number(span.dataset.starCount)
- if (count == null || isNaN(count)) return
- let reviced = 0
- const star = stars?.find((s) => s.id === span.dataset.starId)
- if (star != null) {
- // There is a new star.
- reviced++
- }
- span.textContent = (count + reviced).toLocaleString()
- })
- }, [stars])
-
- const removeButtonEvent = useCallback(() => {
- document.querySelectorAll("button[data-star-id]").forEach((button) => {
- if (!(button instanceof HTMLButtonElement)) return
- button.removeEventListener("click", updateStar)
- })
- }, [updateStar])
-
- useEffect(() => {
- updateButton()
- updateCount()
- addUrlChangeListener(updateButton)
- addUrlChangeListener(updateCount)
- return () => {
- removeUrlChangeListener(updateButton)
- removeUrlChangeListener(updateCount)
- removeButtonEvent()
- }
- }, [stars])
-
- return <>>
-}
diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts
new file mode 100644
index 00000000..fd06a080
--- /dev/null
+++ b/packages/extension/src/hooks/useCommandHubBridge.ts
@@ -0,0 +1,187 @@
+import { useEffect, useRef } from "react"
+import { Ipc, BgCommand } from "@/services/ipc"
+import { useSection } from "@/hooks/useSettings"
+import { CACHE_SECTIONS } from "@/services/settings/settingsCache"
+import {
+ sendEvent,
+ ANALYTICS_EVENTS,
+ getOrCreateClientId,
+} from "@/services/analytics"
+import { SCREEN, NEW_HUB_URL } from "@/const"
+
+const hubOrigin = new URL(NEW_HUB_URL).origin
+
+/**
+ * External postMessage API for adding/deleting commands from the Hub.
+ *
+ * This content script listens for messages from the Hub page (origin must match NEW_HUB_URL).
+ * The message object must have the following shape:
+ *
+ * --- AddCommand ---
+ * {
+ * action: "AddCommand",
+ * command: string // JSON-stringified command object (see below)
+ * }
+ *
+ * The `command` field is a JSON string representing a SearchCommand, an AiPromptCommand, or a PageActionCommand.
+ *
+ * SearchCommand (openMode is one of "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"):
+ * {
+ * id: string, // Unique command identifier
+ * title: string, // Display name of the command
+ * searchUrl: string, // Search URL template (%s is replaced with selected text)
+ * iconUrl: string, // URL of the command icon
+ * openMode: string, // How to open the result: "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"
+ * openModeSecondary?: string, // Secondary open mode (optional)
+ * spaceEncoding?: string, // Space encoding in URL: "plus" | "percent" (optional)
+ * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
+ * sourceId?: string // Identifier of the source (optional)
+ * }
+ *
+ * AiPromptCommand (openMode is "aiPrompt"):
+ * {
+ * id: string, // Unique command identifier
+ * title: string, // Display name of the command
+ * iconUrl: string, // URL of the command icon
+ * openMode: "aiPrompt", // Must be "aiPrompt" for AI prompt commands
+ * aiPromptOption: {
+ * serviceId: string, // ID of the AI service to use (see hub/public/data/ai-services.json)
+ * prompt: string, // Prompt text sent to the AI service (supports variable placeholders)
+ * openMode: string // How to open the AI service result: "popup" | "tab" | "window" | etc.
+ * },
+ * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
+ * sourceId?: string // Identifier of the source (optional)
+ * }
+ *
+ * PageActionCommand (openMode is "pageAction"):
+ * {
+ * id: string, // Unique command identifier
+ * title: string, // Display name of the command
+ * iconUrl: string, // URL of the command icon
+ * openMode: "pageAction", // Must be "pageAction" for page action commands
+ * pageActionOption: {
+ * startUrl: string, // URL to open when executing the page action
+ * pageUrl?: string, // URL pattern for command enablement (currentTab mode only, optional)
+ * openMode: string, // How to open the page: "none" | "popup" | "tab" | "backgroundTab" | "window" | "currentTab"
+ * steps: Array, // Sequence of automation steps to execute
+ * userVariables?: Array<{ name: string, value: string }> // User-defined variables (optional)
+ * },
+ * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional)
+ * sourceId?: string // Identifier of the source (optional)
+ * }
+ *
+ * --- DeleteCommand ---
+ * {
+ * action: "DeleteCommand",
+ * id: string // ID of the command to remove
+ * }
+ *
+ * --- AddCommandAck (response) ---
+ * {
+ * action: "AddCommandAck",
+ * result: boolean, // true if the command was added successfully, false otherwise
+ * install_id: string // stable anonymous identifier per extension install (UUID, persisted in chrome.storage.local)
+ * }
+ *
+ * --- DeleteCommandAck (response) ---
+ * {
+ * action: "DeleteCommandAck",
+ * result: boolean // true if the command was removed successfully, false otherwise
+ * }
+ *
+ * --- RequestInstalledCommand (from Hub) ---
+ * {
+ * action: "RequestInstalledCommand"
+ * }
+ *
+ * --- SyncInstalledCommand (response / proactive push) ---
+ * {
+ * action: "SyncInstalledCommand",
+ * installedIds: string[] // IDs of all currently installed commands
+ * }
+ */
+
+export function useCommandHubBridge() {
+ const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS)
+
+ // Ref keeps the message handler (empty deps) in sync with the latest commands
+ // without needing to recreate the listener on every change.
+ const commandsRef = useRef(commands)
+ useEffect(() => {
+ commandsRef.current = commands
+ }, [commands])
+
+ // Proactively push installed IDs to the Hub whenever the commands list changes.
+ useEffect(() => {
+ if (commands == null) return
+ window.postMessage(
+ {
+ action: "SyncInstalledCommand",
+ installedIds: commands.map((c) => c.id),
+ },
+ hubOrigin,
+ )
+ }, [commands])
+
+ useEffect(() => {
+ const handleMessage = async (event: MessageEvent) => {
+ if (event.origin !== hubOrigin) return
+ const { action, command, id } = event.data ?? {}
+ if (action === "AddCommand") {
+ if (typeof command !== "string") return
+ const install_id = await getOrCreateClientId()
+ Ipc.send(BgCommand.addCommand, { command })
+ .then(async (res) => {
+ ;(event.source as WindowProxy)?.postMessage(
+ { action: "AddCommandAck", result: !!res, install_id },
+ { targetOrigin: event.origin },
+ )
+ if (res) {
+ let commandId: string | undefined
+ try {
+ commandId = JSON.parse(command).id
+ } catch {
+ // Ignore parse errors; analytics will be sent without id
+ }
+ await sendEvent(
+ ANALYTICS_EVENTS.COMMAND_HUB_ADD,
+ { id: commandId },
+ SCREEN.COMMAND_HUB,
+ )
+ }
+ })
+ .catch(() => {
+ ;(event.source as WindowProxy)?.postMessage(
+ { action: "AddCommandAck", result: false, install_id },
+ { targetOrigin: event.origin },
+ )
+ })
+ } else if (action === "DeleteCommand") {
+ if (typeof id !== "string") return
+ Ipc.send(BgCommand.removeCommand, { id })
+ .then((res) => {
+ ;(event.source as WindowProxy)?.postMessage(
+ { action: "DeleteCommandAck", result: !!res },
+ { targetOrigin: event.origin },
+ )
+ })
+ .catch(() => {
+ ;(event.source as WindowProxy)?.postMessage(
+ { action: "DeleteCommandAck", result: false },
+ { targetOrigin: event.origin },
+ )
+ })
+ } else if (action === "RequestInstalledCommand") {
+ const ids = commandsRef.current?.map((c) => c.id) ?? []
+ ;(event.source as WindowProxy)?.postMessage(
+ { action: "SyncInstalledCommand", installedIds: ids },
+ { targetOrigin: event.origin },
+ )
+ }
+ }
+ window.addEventListener("message", handleMessage)
+ return () => {
+ window.removeEventListener("message", handleMessage)
+ }
+ }, [])
+}
diff --git a/packages/extension/src/hooks/useDetectUrlChanged.ts b/packages/extension/src/hooks/useDetectUrlChanged.ts
deleted file mode 100644
index 342571e6..00000000
--- a/packages/extension/src/hooks/useDetectUrlChanged.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useState, useEffect } from "react"
-
-type Listener = () => void
-type Listeners = Listener[]
-
-export function useDetectUrlChanged() {
- const [listener, setListener] = useState([])
-
- const addUrlChangeListener = (l: Listener) => {
- setListener((prev) => [...prev, l])
- }
-
- const removeUrlChangeListener = (l: Listener) => {
- setListener((prev) => prev.filter((f) => f !== l))
- }
-
- useEffect(() => {
- const observeUrlChange = () => {
- let oldHref = document.location.href
- const body = document.querySelector("body")
- const observer = new MutationObserver(() => {
- if (oldHref !== document.location.href) {
- oldHref = document.location.href
- listener.forEach((l) => l())
- }
- })
- observer.observe(body as Node, { childList: true, subtree: true })
- return observer
- }
- const observer = observeUrlChange()
- return () => observer.disconnect()
- }, [listener])
-
- return { addUrlChangeListener, removeUrlChangeListener }
-}
diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts
index 304efc25..a53eaa80 100644
--- a/packages/extension/src/services/analytics.ts
+++ b/packages/extension/src/services/analytics.ts
@@ -77,7 +77,7 @@ export async function sendEvent(
}
}
-async function getOrCreateClientId() {
+export async function getOrCreateClientId() {
let clientId = await Storage.get(LOCAL_STORAGE_KEY.CLIENT_ID)
if (!clientId) {
clientId = crypto.randomUUID()