From c4a4178f18b0711c75f5d64c5fa91e3b5f2dc6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 14 Sep 2026 10:14:15 +0800 Subject: [PATCH] feat(desktop): unify skills and connectors with managed installation and mentions --- desktop/public/connectors/SOURCES.md | 55 + desktop/public/connectors/airtable.svg | 1 + desktop/public/connectors/algorithmic-art.svg | 1 + desktop/public/connectors/amap.svg | 1 + desktop/public/connectors/atlassian.svg | 1 + desktop/public/connectors/baidu-maps.svg | 1 + desktop/public/connectors/bazhuayu.svg | 1 + desktop/public/connectors/canva.svg | 1 + desktop/public/connectors/canvas-design.svg | 1 + desktop/public/connectors/clickup.svg | 1 + desktop/public/connectors/cloudflare.svg | 1 + desktop/public/connectors/context7.svg | 1 + desktop/public/connectors/datayes.svg | 1 + desktop/public/connectors/dingtalk.svg | 1 + desktop/public/connectors/drawio.svg | 21 + desktop/public/connectors/exa.svg | 1 + desktop/public/connectors/feishu.svg | 11 + desktop/public/connectors/figma.svg | 1 + desktop/public/connectors/firecrawl.svg | 1 + desktop/public/connectors/frontend-design.svg | 1 + desktop/public/connectors/github.svg | 8 + desktop/public/connectors/huggingface.svg | 1 + desktop/public/connectors/hyperframes.svg | 15 + desktop/public/connectors/intercom.svg | 1 + desktop/public/connectors/jinshuju.svg | 23 + desktop/public/connectors/jufa.svg | 1 + desktop/public/connectors/kuaicha.svg | 1 + desktop/public/connectors/linear.svg | 1 + desktop/public/connectors/listinggood.svg | 1 + desktop/public/connectors/mcp-builder.svg | 1 + desktop/public/connectors/miro.svg | 1 + desktop/public/connectors/neon.svg | 1 + desktop/public/connectors/notion.svg | 9 + desktop/public/connectors/obsidian.svg | 1 + desktop/public/connectors/pkulaw.svg | 1 + desktop/public/connectors/postman.svg | 1 + desktop/public/connectors/qcc.svg | 1 + desktop/public/connectors/qingflow.svg | 1 + desktop/public/connectors/qveris.svg | 1 + desktop/public/connectors/render.svg | 1 + desktop/public/connectors/sentry.svg | 1 + desktop/public/connectors/sorftime.svg | 1 + desktop/public/connectors/stripe.svg | 1 + desktop/public/connectors/supabase.svg | 1 + desktop/public/connectors/tavily.svg | 4 + desktop/public/connectors/tencent-docs.svg | 1 + desktop/public/connectors/tencent-maps.svg | 1 + desktop/public/connectors/tencent-search.svg | 1 + desktop/public/connectors/todoist.svg | 1 + desktop/public/connectors/variflight.svg | 1 + desktop/public/connectors/webapp-testing.svg | 1 + desktop/public/connectors/webflow.svg | 1 + desktop/public/connectors/wecom.svg | 12 + desktop/public/connectors/xmind.svg | 1 + desktop/public/connectors/zhipu-search.svg | 1 + desktop/src/api/composerReferences.test.ts | 8 + desktop/src/api/composerReferences.ts | 9 + desktop/src/api/connectors.test.ts | 10 + desktop/src/api/connectors.ts | 9 + .../src/components/chat/ChatInput.test.tsx | 52 +- desktop/src/components/chat/ChatInput.tsx | 122 ++- .../chat/ComposerReferenceDetail.test.tsx | 11 + .../chat/ComposerReferenceDetail.tsx | 18 + .../chat/ComposerReferenceMenu.test.tsx | 91 ++ .../components/chat/ComposerReferenceMenu.tsx | 146 +++ .../components/chat/FileSearchMenu.test.tsx | 202 ---- .../src/components/chat/FileSearchMenu.tsx | 330 ------ .../src/components/chat/MentionComposer.tsx | 17 +- .../components/chat/SlashCommandMenu.test.tsx | 22 + .../src/components/chat/SlashCommandMenu.tsx | 80 +- .../components/chat/composerEditor.test.ts | 24 + desktop/src/components/chat/composerEditor.ts | 61 +- .../src/components/chat/composerUtils.test.ts | 10 + desktop/src/components/chat/composerUtils.ts | 9 +- .../connectors/ConnectorRow.test.tsx | 36 + .../components/connectors/ConnectorRow.tsx | 31 + .../connectors/catalogTranslations.test.ts | 26 + .../src/components/connectors/model.test.ts | 55 + desktop/src/components/connectors/model.ts | 32 + .../components/layout/ContentRouter.test.tsx | 10 +- .../src/components/layout/ContentRouter.tsx | 6 +- .../src/components/layout/Sidebar.test.tsx | 12 +- desktop/src/components/layout/Sidebar.tsx | 11 +- desktop/src/components/layout/TabBar.test.tsx | 5 +- desktop/src/components/layout/TabBar.tsx | 5 +- .../src/components/market/MarketHome.test.tsx | 7 + desktop/src/components/market/MarketHome.tsx | 6 +- desktop/src/components/skills/SkillDetail.tsx | 15 +- desktop/src/components/skills/SkillList.tsx | 14 +- desktop/src/i18n/locales/en.ts | 351 +++++++ desktop/src/i18n/locales/jp.ts | 351 +++++++ desktop/src/i18n/locales/kr.ts | 351 +++++++ desktop/src/i18n/locales/zh-TW.ts | 351 +++++++ desktop/src/i18n/locales/zh.ts | 350 +++++++ desktop/src/lib/composerMentions.ts | 25 +- desktop/src/lib/persistenceMigrations.test.ts | 38 + desktop/src/lib/persistenceMigrations.ts | 18 +- desktop/src/pages/Connectors.test.tsx | 393 +++++++ desktop/src/pages/Connectors.tsx | 228 ++++ desktop/src/pages/EmptySession.test.tsx | 92 ++ desktop/src/pages/EmptySession.tsx | 112 +- desktop/src/pages/ExtensionMarket.test.tsx | 41 + desktop/src/pages/ExtensionMarket.tsx | 34 + desktop/src/pages/InstalledSkills.test.tsx | 85 ++ desktop/src/pages/InstalledSkills.tsx | 26 + desktop/src/pages/Market.tsx | 6 +- desktop/src/stores/connectorStore.test.ts | 32 + desktop/src/stores/connectorStore.ts | 43 + desktop/src/stores/tabStore.test.ts | 32 +- desktop/src/stores/tabStore.ts | 24 +- desktop/src/theme/globals.css | 30 + desktop/src/types/composerReference.ts | 13 + desktop/src/types/connector.ts | 1 + desktop/src/types/slashCommand.ts | 2 +- scripts/quality-gate/persistence-upgrade.ts | 4 + src/server/__tests__/skills.test.ts | 54 + src/server/api/connectors.test.ts | 50 + src/server/api/connectors.ts | 36 + src/server/api/skills.ts | 4 + src/server/router.ts | 3 + .../services/capabilityMentionService.test.ts | 76 ++ .../services/capabilityMentionService.ts | 85 ++ src/server/services/connectorService.test.ts | 381 +++++++ src/server/services/connectorService.ts | 277 +++++ .../services/connectorsPersistence.test.ts | 32 + src/server/services/connectorsPersistence.ts | 46 + src/services/connectors/README.md | 228 ++++ src/services/connectors/catalog.test.ts | 91 ++ src/services/connectors/catalog.ts | 17 + src/services/connectors/cliAdapter.test.ts | 142 +++ src/services/connectors/cliAdapter.ts | 122 +++ .../connectors/domesticCatalog.test.ts | 33 + src/services/connectors/domesticCatalog.ts | 18 + .../connectors/fixtures/status-output.json | 12 + src/services/connectors/globalCatalog.ts | 188 ++++ .../connectors/managedRuntime.test.ts | 136 +++ src/services/connectors/managedRuntime.ts | 261 +++++ src/services/connectors/pluginBridge.test.ts | 148 +++ src/services/connectors/pluginBridge.ts | 267 +++++ src/services/connectors/remoteAdapter.test.ts | 82 ++ src/services/connectors/remoteAdapter.ts | 65 ++ src/services/connectors/remoteCatalog.ts | 55 + .../connectors/remoteConnector.test.ts | 95 ++ src/services/connectors/remoteConnector.ts | 165 +++ src/services/connectors/research.md | 153 +++ src/services/connectors/skillAdapter.test.ts | 58 ++ src/services/connectors/skillAdapter.ts | 87 ++ .../connectors/skillBundles.lock.json | 974 ++++++++++++++++++ src/services/connectors/skillCatalog.test.ts | 50 + src/services/connectors/skillCatalog.ts | 33 + src/services/connectors/types.ts | 98 ++ src/types/composerMention.ts | 20 + src/utils/plugins/loadPluginCommands.ts | 13 +- src/utils/plugins/pluginLoader.ts | 32 +- 154 files changed, 8705 insertions(+), 721 deletions(-) create mode 100644 desktop/public/connectors/SOURCES.md create mode 100644 desktop/public/connectors/airtable.svg create mode 100644 desktop/public/connectors/algorithmic-art.svg create mode 100644 desktop/public/connectors/amap.svg create mode 100644 desktop/public/connectors/atlassian.svg create mode 100644 desktop/public/connectors/baidu-maps.svg create mode 100644 desktop/public/connectors/bazhuayu.svg create mode 100644 desktop/public/connectors/canva.svg create mode 100644 desktop/public/connectors/canvas-design.svg create mode 100644 desktop/public/connectors/clickup.svg create mode 100644 desktop/public/connectors/cloudflare.svg create mode 100644 desktop/public/connectors/context7.svg create mode 100644 desktop/public/connectors/datayes.svg create mode 100644 desktop/public/connectors/dingtalk.svg create mode 100644 desktop/public/connectors/drawio.svg create mode 100644 desktop/public/connectors/exa.svg create mode 100644 desktop/public/connectors/feishu.svg create mode 100644 desktop/public/connectors/figma.svg create mode 100644 desktop/public/connectors/firecrawl.svg create mode 100644 desktop/public/connectors/frontend-design.svg create mode 100644 desktop/public/connectors/github.svg create mode 100644 desktop/public/connectors/huggingface.svg create mode 100644 desktop/public/connectors/hyperframes.svg create mode 100644 desktop/public/connectors/intercom.svg create mode 100644 desktop/public/connectors/jinshuju.svg create mode 100644 desktop/public/connectors/jufa.svg create mode 100644 desktop/public/connectors/kuaicha.svg create mode 100644 desktop/public/connectors/linear.svg create mode 100644 desktop/public/connectors/listinggood.svg create mode 100644 desktop/public/connectors/mcp-builder.svg create mode 100644 desktop/public/connectors/miro.svg create mode 100644 desktop/public/connectors/neon.svg create mode 100644 desktop/public/connectors/notion.svg create mode 100644 desktop/public/connectors/obsidian.svg create mode 100644 desktop/public/connectors/pkulaw.svg create mode 100644 desktop/public/connectors/postman.svg create mode 100644 desktop/public/connectors/qcc.svg create mode 100644 desktop/public/connectors/qingflow.svg create mode 100644 desktop/public/connectors/qveris.svg create mode 100644 desktop/public/connectors/render.svg create mode 100644 desktop/public/connectors/sentry.svg create mode 100644 desktop/public/connectors/sorftime.svg create mode 100644 desktop/public/connectors/stripe.svg create mode 100644 desktop/public/connectors/supabase.svg create mode 100644 desktop/public/connectors/tavily.svg create mode 100644 desktop/public/connectors/tencent-docs.svg create mode 100644 desktop/public/connectors/tencent-maps.svg create mode 100644 desktop/public/connectors/tencent-search.svg create mode 100644 desktop/public/connectors/todoist.svg create mode 100644 desktop/public/connectors/variflight.svg create mode 100644 desktop/public/connectors/webapp-testing.svg create mode 100644 desktop/public/connectors/webflow.svg create mode 100644 desktop/public/connectors/wecom.svg create mode 100644 desktop/public/connectors/xmind.svg create mode 100644 desktop/public/connectors/zhipu-search.svg create mode 100644 desktop/src/api/composerReferences.test.ts create mode 100644 desktop/src/api/composerReferences.ts create mode 100644 desktop/src/api/connectors.test.ts create mode 100644 desktop/src/api/connectors.ts create mode 100644 desktop/src/components/chat/ComposerReferenceDetail.test.tsx create mode 100644 desktop/src/components/chat/ComposerReferenceDetail.tsx create mode 100644 desktop/src/components/chat/ComposerReferenceMenu.test.tsx create mode 100644 desktop/src/components/chat/ComposerReferenceMenu.tsx delete mode 100644 desktop/src/components/chat/FileSearchMenu.test.tsx delete mode 100644 desktop/src/components/chat/FileSearchMenu.tsx create mode 100644 desktop/src/components/connectors/ConnectorRow.test.tsx create mode 100644 desktop/src/components/connectors/ConnectorRow.tsx create mode 100644 desktop/src/components/connectors/catalogTranslations.test.ts create mode 100644 desktop/src/components/connectors/model.test.ts create mode 100644 desktop/src/components/connectors/model.ts create mode 100644 desktop/src/pages/Connectors.test.tsx create mode 100644 desktop/src/pages/Connectors.tsx create mode 100644 desktop/src/pages/ExtensionMarket.test.tsx create mode 100644 desktop/src/pages/ExtensionMarket.tsx create mode 100644 desktop/src/pages/InstalledSkills.test.tsx create mode 100644 desktop/src/pages/InstalledSkills.tsx create mode 100644 desktop/src/stores/connectorStore.test.ts create mode 100644 desktop/src/stores/connectorStore.ts create mode 100644 desktop/src/types/composerReference.ts create mode 100644 desktop/src/types/connector.ts create mode 100644 src/server/api/connectors.test.ts create mode 100644 src/server/api/connectors.ts create mode 100644 src/server/services/capabilityMentionService.test.ts create mode 100644 src/server/services/capabilityMentionService.ts create mode 100644 src/server/services/connectorService.test.ts create mode 100644 src/server/services/connectorService.ts create mode 100644 src/server/services/connectorsPersistence.test.ts create mode 100644 src/server/services/connectorsPersistence.ts create mode 100644 src/services/connectors/README.md create mode 100644 src/services/connectors/catalog.test.ts create mode 100644 src/services/connectors/catalog.ts create mode 100644 src/services/connectors/cliAdapter.test.ts create mode 100644 src/services/connectors/cliAdapter.ts create mode 100644 src/services/connectors/domesticCatalog.test.ts create mode 100644 src/services/connectors/domesticCatalog.ts create mode 100644 src/services/connectors/fixtures/status-output.json create mode 100644 src/services/connectors/globalCatalog.ts create mode 100644 src/services/connectors/managedRuntime.test.ts create mode 100644 src/services/connectors/managedRuntime.ts create mode 100644 src/services/connectors/pluginBridge.test.ts create mode 100644 src/services/connectors/pluginBridge.ts create mode 100644 src/services/connectors/remoteAdapter.test.ts create mode 100644 src/services/connectors/remoteAdapter.ts create mode 100644 src/services/connectors/remoteCatalog.ts create mode 100644 src/services/connectors/remoteConnector.test.ts create mode 100644 src/services/connectors/remoteConnector.ts create mode 100644 src/services/connectors/research.md create mode 100644 src/services/connectors/skillAdapter.test.ts create mode 100644 src/services/connectors/skillAdapter.ts create mode 100644 src/services/connectors/skillBundles.lock.json create mode 100644 src/services/connectors/skillCatalog.test.ts create mode 100644 src/services/connectors/skillCatalog.ts create mode 100644 src/services/connectors/types.ts create mode 100644 src/types/composerMention.ts diff --git a/desktop/public/connectors/SOURCES.md b/desktop/public/connectors/SOURCES.md new file mode 100644 index 00000000..a36e3634 --- /dev/null +++ b/desktop/public/connectors/SOURCES.md @@ -0,0 +1,55 @@ +Service brand marks identify the corresponding services; no endorsement implied. + +Source: WorkBuddy public connector catalog, https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/feishu.svg, icons/dingtalk.svg, icons/wecom.svg), retrieved 2026-09-13. + +Additional service marks, retrieved 2026-09-13: +- github: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/github.svg) +- notion: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/notion.svg) +- canva: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/canva.png) +- tencent-maps: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/tencent-map.png) +- tencent-docs: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/tencent-docs.png) +- datayes: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/datayes-data.png) +- pkulaw: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/pkulaw.svg) +- jinshuju: https://acc-1258344699.cos.ap-guangzhou.myqcloud.com/connectors-config-v2/connectors-config.zip (icons/jinshuju.svg) +- linear: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/linear.svg) +- supabase: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/supabase.svg) +- sentry: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/sentry.svg) +- stripe: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/stripe.svg) +- figma: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/figma.svg) +- huggingface: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/huggingface.svg) +- baidu-maps: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/baidu.svg) +- amap: https://a.amap.com/pc/static/favicon.ico +- tavily: https://www.tavily.com/icon.svg?icon.0v6b1ltlljvdd.svg +- exa: https://exa.ai/images/favicon-32x32.png +- context7: https://context7.com/favicon.ico +- zhipu-search: https://bigmodel.cn/static/images/favicon.png +Simple Icons assets use CC0; service trademarks remain their owners' marks. +- tencent-search: https://cloudcache.tencent-cloud.com/qcloud/favicon.ico?t=201902181234 (Tencent Cloud product family icon) + +Dark/monochrome marks have a light backplate so the original mark remains legible in dark themes. + +2026-09-14 global service marks: neon, cloudflare, firecrawl, clickup, miro, postman, render, airtable, atlassian, webflow, todoist, intercom. Source: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/; CC0, trademarks belong to service owners). +Firecrawl exception: official favicon https://www.firecrawl.dev/favicon.png (not in Simple Icons). + +### 国内目录补充(2026-09-14) + +下列图片均取自对应供应商官网标识。ICO/PNG 通过系统 sips 转为最大 128px PNG,再以本地 SVG 的 data:image/png 包装,避免远程加载。ListingGood 保留官网内联 favicon SVG;飞常准保留官方 SVG 路径并去除 XML/外部 DTD 声明。仅为产品识别,不表示供应商合作或背书。 + +| 文件 | 官方图片来源 / 发现页 | +| --- | --- | +| qingflow.svg | https://qingflow.com/favicon.ico | +| qcc.svg | https://agent.qcc.com/favicon-qcc.png (官网 /guide 的 icon 链接) | +| xmind.svg | https://framerusercontent.com/images/PvPQXjvou7vNNpBBUrFlOgF4MCI.png (https://xmind.cn 的 favicon 链接) | +| kuaicha.svg | https://s.thsi.cn/cd/kc-menhu-pc/public/img/logo.png (快查开放平台官网品牌图) | +| qveris.svg | https://qveris.cn/apple-touch-icon.png | +| listinggood.svg | https://listinggood.cn/ 的内联 data:image/svg+xml favicon | +| bazhuayu.svg | https://www.bazhuayu.com/favicon.ico?v1 | +| variflight.svg | https://static.variflight.com/assets/mcp/assets/variflight-logo.svg (官方 MCP 页 favicon) | +| jufa.svg | https://www.jufaai.com/favicon.ico | +| sorftime.svg | https://www.sorftime.com/content/home/img/favicon.ico?v=2.9.9 | + +2026-09-14 independent skill tool icons: +- hyperframes: official HeyGen source, https://raw.githubusercontent.com/heygen-com/hyperframes/60780774bb1896373947fb3e3197117501f53a22/docs/logo/symbol-light.svg +- drawio: official draw.io source, https://raw.githubusercontent.com/jgraph/drawio-mcp/14b318b19cc37b159f841227b9d11fbd18ce18ea/plugins/codex/drawio/assets/drawio-logo.svg +- obsidian: https://registry.npmjs.org/simple-icons/-/simple-icons-16.31.0.tgz (icons/obsidian.svg; CC0 silhouette, purple presentation fill). +- frontend-design, canvas-design, algorithmic-art, webapp-testing, mcp-builder: original neutral tool glyphs created for this application. These are category illustrations, not Anthropic brand marks. diff --git a/desktop/public/connectors/airtable.svg b/desktop/public/connectors/airtable.svg new file mode 100644 index 00000000..b6342867 --- /dev/null +++ b/desktop/public/connectors/airtable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/algorithmic-art.svg b/desktop/public/connectors/algorithmic-art.svg new file mode 100644 index 00000000..5b49f2ae --- /dev/null +++ b/desktop/public/connectors/algorithmic-art.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/amap.svg b/desktop/public/connectors/amap.svg new file mode 100644 index 00000000..833695c3 --- /dev/null +++ b/desktop/public/connectors/amap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/atlassian.svg b/desktop/public/connectors/atlassian.svg new file mode 100644 index 00000000..7aed1ec6 --- /dev/null +++ b/desktop/public/connectors/atlassian.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/baidu-maps.svg b/desktop/public/connectors/baidu-maps.svg new file mode 100644 index 00000000..1a8d53fe --- /dev/null +++ b/desktop/public/connectors/baidu-maps.svg @@ -0,0 +1 @@ +Baidu \ No newline at end of file diff --git a/desktop/public/connectors/bazhuayu.svg b/desktop/public/connectors/bazhuayu.svg new file mode 100644 index 00000000..c7de987e --- /dev/null +++ b/desktop/public/connectors/bazhuayu.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/canva.svg b/desktop/public/connectors/canva.svg new file mode 100644 index 00000000..43609aac --- /dev/null +++ b/desktop/public/connectors/canva.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/canvas-design.svg b/desktop/public/connectors/canvas-design.svg new file mode 100644 index 00000000..6b4ca8ce --- /dev/null +++ b/desktop/public/connectors/canvas-design.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/clickup.svg b/desktop/public/connectors/clickup.svg new file mode 100644 index 00000000..6c4c94ae --- /dev/null +++ b/desktop/public/connectors/clickup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/cloudflare.svg b/desktop/public/connectors/cloudflare.svg new file mode 100644 index 00000000..1321549d --- /dev/null +++ b/desktop/public/connectors/cloudflare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/context7.svg b/desktop/public/connectors/context7.svg new file mode 100644 index 00000000..a5f72579 --- /dev/null +++ b/desktop/public/connectors/context7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/datayes.svg b/desktop/public/connectors/datayes.svg new file mode 100644 index 00000000..ec1693dd --- /dev/null +++ b/desktop/public/connectors/datayes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/dingtalk.svg b/desktop/public/connectors/dingtalk.svg new file mode 100644 index 00000000..323aaf14 --- /dev/null +++ b/desktop/public/connectors/dingtalk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/drawio.svg b/desktop/public/connectors/drawio.svg new file mode 100644 index 00000000..01187fb6 --- /dev/null +++ b/desktop/public/connectors/drawio.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/desktop/public/connectors/exa.svg b/desktop/public/connectors/exa.svg new file mode 100644 index 00000000..f4a51930 --- /dev/null +++ b/desktop/public/connectors/exa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/feishu.svg b/desktop/public/connectors/feishu.svg new file mode 100644 index 00000000..1d50dfee --- /dev/null +++ b/desktop/public/connectors/feishu.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/desktop/public/connectors/figma.svg b/desktop/public/connectors/figma.svg new file mode 100644 index 00000000..dc447fcc --- /dev/null +++ b/desktop/public/connectors/figma.svg @@ -0,0 +1 @@ +Figma \ No newline at end of file diff --git a/desktop/public/connectors/firecrawl.svg b/desktop/public/connectors/firecrawl.svg new file mode 100644 index 00000000..e3a1b7ae --- /dev/null +++ b/desktop/public/connectors/firecrawl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/frontend-design.svg b/desktop/public/connectors/frontend-design.svg new file mode 100644 index 00000000..a26ea8f1 --- /dev/null +++ b/desktop/public/connectors/frontend-design.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/github.svg b/desktop/public/connectors/github.svg new file mode 100644 index 00000000..02828fcc --- /dev/null +++ b/desktop/public/connectors/github.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/desktop/public/connectors/huggingface.svg b/desktop/public/connectors/huggingface.svg new file mode 100644 index 00000000..a52cbe3e --- /dev/null +++ b/desktop/public/connectors/huggingface.svg @@ -0,0 +1 @@ +Hugging Face \ No newline at end of file diff --git a/desktop/public/connectors/hyperframes.svg b/desktop/public/connectors/hyperframes.svg new file mode 100644 index 00000000..00e49620 --- /dev/null +++ b/desktop/public/connectors/hyperframes.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/public/connectors/intercom.svg b/desktop/public/connectors/intercom.svg new file mode 100644 index 00000000..5ed1d421 --- /dev/null +++ b/desktop/public/connectors/intercom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/jinshuju.svg b/desktop/public/connectors/jinshuju.svg new file mode 100644 index 00000000..b3af6c9d --- /dev/null +++ b/desktop/public/connectors/jinshuju.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/public/connectors/jufa.svg b/desktop/public/connectors/jufa.svg new file mode 100644 index 00000000..d5f1458f --- /dev/null +++ b/desktop/public/connectors/jufa.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/kuaicha.svg b/desktop/public/connectors/kuaicha.svg new file mode 100644 index 00000000..7965089c --- /dev/null +++ b/desktop/public/connectors/kuaicha.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/linear.svg b/desktop/public/connectors/linear.svg new file mode 100644 index 00000000..9ac44813 --- /dev/null +++ b/desktop/public/connectors/linear.svg @@ -0,0 +1 @@ +Linear \ No newline at end of file diff --git a/desktop/public/connectors/listinggood.svg b/desktop/public/connectors/listinggood.svg new file mode 100644 index 00000000..7d51c137 --- /dev/null +++ b/desktop/public/connectors/listinggood.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/mcp-builder.svg b/desktop/public/connectors/mcp-builder.svg new file mode 100644 index 00000000..f391d61a --- /dev/null +++ b/desktop/public/connectors/mcp-builder.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/miro.svg b/desktop/public/connectors/miro.svg new file mode 100644 index 00000000..d07f6fcd --- /dev/null +++ b/desktop/public/connectors/miro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/neon.svg b/desktop/public/connectors/neon.svg new file mode 100644 index 00000000..02371d82 --- /dev/null +++ b/desktop/public/connectors/neon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/notion.svg b/desktop/public/connectors/notion.svg new file mode 100644 index 00000000..322924d4 --- /dev/null +++ b/desktop/public/connectors/notion.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/desktop/public/connectors/obsidian.svg b/desktop/public/connectors/obsidian.svg new file mode 100644 index 00000000..cb0b382d --- /dev/null +++ b/desktop/public/connectors/obsidian.svg @@ -0,0 +1 @@ +Obsidian \ No newline at end of file diff --git a/desktop/public/connectors/pkulaw.svg b/desktop/public/connectors/pkulaw.svg new file mode 100644 index 00000000..8ae3f7f4 --- /dev/null +++ b/desktop/public/connectors/pkulaw.svg @@ -0,0 +1 @@ +.cls-1{fill:#9a0000;}logo \ No newline at end of file diff --git a/desktop/public/connectors/postman.svg b/desktop/public/connectors/postman.svg new file mode 100644 index 00000000..6cdd6d42 --- /dev/null +++ b/desktop/public/connectors/postman.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/qcc.svg b/desktop/public/connectors/qcc.svg new file mode 100644 index 00000000..f6a99ecc --- /dev/null +++ b/desktop/public/connectors/qcc.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/qingflow.svg b/desktop/public/connectors/qingflow.svg new file mode 100644 index 00000000..2a3fe2cb --- /dev/null +++ b/desktop/public/connectors/qingflow.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/qveris.svg b/desktop/public/connectors/qveris.svg new file mode 100644 index 00000000..39fdc3ff --- /dev/null +++ b/desktop/public/connectors/qveris.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/render.svg b/desktop/public/connectors/render.svg new file mode 100644 index 00000000..fc8f3550 --- /dev/null +++ b/desktop/public/connectors/render.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/sentry.svg b/desktop/public/connectors/sentry.svg new file mode 100644 index 00000000..3d57b5c2 --- /dev/null +++ b/desktop/public/connectors/sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/desktop/public/connectors/sorftime.svg b/desktop/public/connectors/sorftime.svg new file mode 100644 index 00000000..336e3234 --- /dev/null +++ b/desktop/public/connectors/sorftime.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/stripe.svg b/desktop/public/connectors/stripe.svg new file mode 100644 index 00000000..48272a2c --- /dev/null +++ b/desktop/public/connectors/stripe.svg @@ -0,0 +1 @@ +Stripe \ No newline at end of file diff --git a/desktop/public/connectors/supabase.svg b/desktop/public/connectors/supabase.svg new file mode 100644 index 00000000..03f46fa2 --- /dev/null +++ b/desktop/public/connectors/supabase.svg @@ -0,0 +1 @@ +Supabase \ No newline at end of file diff --git a/desktop/public/connectors/tavily.svg b/desktop/public/connectors/tavily.svg new file mode 100644 index 00000000..4789acc4 --- /dev/null +++ b/desktop/public/connectors/tavily.svg @@ -0,0 +1,4 @@ + + + + diff --git a/desktop/public/connectors/tencent-docs.svg b/desktop/public/connectors/tencent-docs.svg new file mode 100644 index 00000000..c9b3196a --- /dev/null +++ b/desktop/public/connectors/tencent-docs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/tencent-maps.svg b/desktop/public/connectors/tencent-maps.svg new file mode 100644 index 00000000..29386f2e --- /dev/null +++ b/desktop/public/connectors/tencent-maps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/tencent-search.svg b/desktop/public/connectors/tencent-search.svg new file mode 100644 index 00000000..ef30ac86 --- /dev/null +++ b/desktop/public/connectors/tencent-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/todoist.svg b/desktop/public/connectors/todoist.svg new file mode 100644 index 00000000..b4096dff --- /dev/null +++ b/desktop/public/connectors/todoist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/variflight.svg b/desktop/public/connectors/variflight.svg new file mode 100644 index 00000000..509391f8 --- /dev/null +++ b/desktop/public/connectors/variflight.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/webapp-testing.svg b/desktop/public/connectors/webapp-testing.svg new file mode 100644 index 00000000..1ddf7bed --- /dev/null +++ b/desktop/public/connectors/webapp-testing.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/webflow.svg b/desktop/public/connectors/webflow.svg new file mode 100644 index 00000000..946a19fb --- /dev/null +++ b/desktop/public/connectors/webflow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/public/connectors/wecom.svg b/desktop/public/connectors/wecom.svg new file mode 100644 index 00000000..1abed943 --- /dev/null +++ b/desktop/public/connectors/wecom.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/desktop/public/connectors/xmind.svg b/desktop/public/connectors/xmind.svg new file mode 100644 index 00000000..1420d026 --- /dev/null +++ b/desktop/public/connectors/xmind.svg @@ -0,0 +1 @@ + diff --git a/desktop/public/connectors/zhipu-search.svg b/desktop/public/connectors/zhipu-search.svg new file mode 100644 index 00000000..a6a62c35 --- /dev/null +++ b/desktop/public/connectors/zhipu-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/src/api/composerReferences.test.ts b/desktop/src/api/composerReferences.test.ts new file mode 100644 index 00000000..41473717 --- /dev/null +++ b/desktop/src/api/composerReferences.test.ts @@ -0,0 +1,8 @@ +import { expect, it, vi } from 'vitest' +import { api } from '@/api/client' +import { composerReferencesApi } from './composerReferences' +vi.mock('@/api/client', () => ({ api: { get: vi.fn() } })) +it('requests available capabilities for the exact workspace without credentials or catalog installation', () => { + composerReferencesApi.list('/repo/中文 folder') + expect(api.get).toHaveBeenCalledWith('/api/skills/mentions?cwd=%2Frepo%2F%E4%B8%AD%E6%96%87%20folder') +}) diff --git a/desktop/src/api/composerReferences.ts b/desktop/src/api/composerReferences.ts new file mode 100644 index 00000000..a3d375ba --- /dev/null +++ b/desktop/src/api/composerReferences.ts @@ -0,0 +1,9 @@ +import { api } from './client' +import type { ComposerReferenceCandidate } from '@/types/composerReference' + +export const composerReferencesApi = { + list(cwd?: string) { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ skills: ComposerReferenceCandidate[], plugins: ComposerReferenceCandidate[] }>(`/api/skills/mentions${query}`) + }, +} diff --git a/desktop/src/api/connectors.test.ts b/desktop/src/api/connectors.test.ts new file mode 100644 index 00000000..11a364ec --- /dev/null +++ b/desktop/src/api/connectors.test.ts @@ -0,0 +1,10 @@ +import { expect, it, vi } from 'vitest' +import { api } from '@/api/client' +import { connectorsApi } from './connectors' +vi.mock('@/api/client', () => ({ api: { get: vi.fn(), post: vi.fn() } })) +it('passes explicit shared-credential acknowledgment only in the requested action', () => { + connectorsApi.action('feishu', 'authenticate', { acknowledgeSharedCredentials: true }) + expect(api.post).toHaveBeenCalledWith('/api/connectors/feishu/authenticate', { acknowledgeSharedCredentials: true }) + connectorsApi.list() + expect(api.get).toHaveBeenCalledWith('/api/connectors', { signal: undefined }) +}) diff --git a/desktop/src/api/connectors.ts b/desktop/src/api/connectors.ts new file mode 100644 index 00000000..67d23d54 --- /dev/null +++ b/desktop/src/api/connectors.ts @@ -0,0 +1,9 @@ +import { api } from '@/api/client' +import type { ConnectorAction, ConnectorActionOptions, ConnectorDto, ConnectorId } from '@/types/connector' + +export const connectorsApi = { + list: (signal?: AbortSignal) => api.get<{ items: ConnectorDto[] }>('/api/connectors', { signal }), + detail: (id: ConnectorId, signal?: AbortSignal) => api.get<{ connector: ConnectorDto }>(`/api/connectors/${encodeURIComponent(id)}`, { signal }), + action: (id: ConnectorId, action: ConnectorAction, options: ConnectorActionOptions = {}) => + api.post<{ connector: ConnectorDto }>(`/api/connectors/${encodeURIComponent(id)}/${action}`, options), +} diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index c3fb12eb..14f75d9f 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ getGitInfo: vi.fn(), getSlashCommands: vi.fn(), listAgents: vi.fn(), + listReferences: vi.fn(), getRepositoryContext: vi.fn(), createRepositoryBranch: vi.fn(), getRecentProjects: vi.fn(), @@ -44,6 +45,10 @@ vi.mock('../../api/sessions', () => ({ }, })) +vi.mock('../../api/composerReferences', () => ({ + composerReferencesApi: { list: mocks.listReferences }, +})) + vi.mock('../../api/agents', () => ({ agentsApi: { list: mocks.listAgents, @@ -288,6 +293,9 @@ describe('ChatInput file mentions', () => { mocks.getMessages.mockResolvedValue({ messages: [] }) mocks.getSlashCommands.mockResolvedValue({ commands: [] }) mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] }) + mocks.listReferences.mockResolvedValue({ skills: [], plugins: [] }) + mocks.browse.mockResolvedValue({ currentPath: '/repo', parentPath: null, entries: [] }) + mocks.search.mockResolvedValue({ currentPath: '/repo', parentPath: null, entries: [] }) }) afterEach(() => { @@ -1675,6 +1683,46 @@ describe('ChatInput file mentions', () => { }, ) + it.each(['@', '/'])('selects an installed plugin with %s as a clickable chip before sending its explicit identity', async (trigger) => { + const modelText = 'Use plugin "hyperframes@curated" and its available skills for this request.' + mocks.listReferences.mockResolvedValue({ skills: [], plugins: [{ + kind: 'plugin', id: 'hyperframes@curated', name: 'hyperframes', displayName: 'HyperFrames', + description: 'Create HTML videos', source: 'curated', icon: '/connectors/hyperframes.svg', modelText, + }] }) + render() + setComposerText(`${trigger}hyper`, 6) + fireEvent.click(await screen.findByRole('option', { name: /hyperframes/i })) + await waitFor(() => expect(document.querySelector('[data-mention-kind="plugin"]')).toBeInTheDocument()) + expect(mocks.wsSend).not.toHaveBeenCalled() + const chip = document.querySelector('[data-mention-kind="plugin"]') as HTMLElement + expect(chip.querySelector('img')).toHaveAttribute('src', '/connectors/hyperframes.svg') + fireEvent.keyDown(chip, { key: 'Enter' }) + expect(await screen.findByRole('dialog', { name: 'HyperFrames' })).toHaveTextContent('Create HTML videos') + expect(mocks.wsSend).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', content: modelText, attachments: [], + }) + }) + + it('selects an exact slash skill on Enter without executing and preserves its canonical identity', async () => { + mocks.listReferences.mockResolvedValue({ plugins: [], skills: [{ + kind: 'skill', id: 'skill:team:review', name: 'team:review', displayName: 'Review', + description: 'Review a change', source: 'plugin', modelText: 'Use the Skill tool with skill: "team:review" for this request.', + }] }) + render() + setComposerText('/team:review', 12) + await screen.findByRole('option', { name: /Review/i }) + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + await waitFor(() => expect(document.querySelector('[data-mention-kind="skill"]')).toBeInTheDocument()) + expect(mocks.wsSend).not.toHaveBeenCalled() + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', content: 'Use the Skill tool with skill: "team:review" for this request.', attachments: [], + }) + }) + it('inserts a selected @ file as an inline mention pill and sends its absolute path', async () => { mocks.search.mockResolvedValueOnce({ currentPath: '/repo/backend/src', @@ -1690,7 +1738,7 @@ describe('ChatInput file mentions', () => { const mention = '@backend/src/conditions.py' setComposerText(`${mention} 记一下这个文件讲了什么东西。`, mention.length) - fireEvent.click(await screen.findByText('backend/src/conditions.py')) + fireEvent.click(await screen.findByRole('option', { name: 'conditions.py' })) // The trigger text becomes an inline pill — no attachment chip is added. await waitFor(() => { @@ -2174,7 +2222,7 @@ describe('ChatInput file mentions', () => { setComposerText('@cond', 5) expect(await screen.findByText('conditions.py')).toBeInTheDocument() - const fileSearchMenu = document.getElementById('file-search-menu') + const fileSearchMenu = screen.getByRole('listbox', { name: 'References' }) expect(fileSearchMenu).toHaveClass('min-w-0') expect(fileSearchMenu).not.toHaveClass('min-w-[480px]') expect(fileSearchMenu).not.toHaveTextContent('Navigate') diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 38b42335..a08dd7e1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -24,7 +24,10 @@ import { AttachmentGallery } from './AttachmentGallery' import { ComposerDropOverlay } from './ComposerDropOverlay' import { ProjectContextChip } from '@/components/chat/ProjectContextChip' import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchControls' -import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' +import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu' +import { ComposerReferenceDetail } from './ComposerReferenceDetail' +import { composerReferencesApi } from '@/api/composerReferences' +import type { ComposerReferenceCandidate } from '@/types/composerReference' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu' import { ContextUsageIndicator } from './ContextUsageIndicator' @@ -52,6 +55,7 @@ import { useComposerFileDrop } from './useComposerFileDrop' import { shouldSubmitOnEnter } from './sendShortcut' import { MentionComposer, type MentionComposerHandle } from './MentionComposer' import { + composerReferenceToMention, findMentionRanges, insertMentionIntoText, type ComposerMention, @@ -69,6 +73,8 @@ type ChatInputProps = { compact?: boolean } +const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = [] + const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] /** @@ -131,6 +137,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const [shellRef, shellWidth] = useElementWidth() const [input, setInput] = useState('') const [mentions, setMentions] = useState([]) + const [referenceDetail, setReferenceDetail] = useState(null) + const [referenceOptionId, setReferenceOptionId] = useState() + const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null) const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) @@ -153,9 +162,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const modelSelectorRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) - const fileSearchRef = useRef(null) + const fileSearchRef = useRef(null) const slashItemRefs = useRef<(HTMLElement | null)[]>([]) const slashMenuId = useId() + const referenceMenuId = useId() const previousActiveTabIdRef = useRef(null) const inputRef = useRef(input) const mentionsRef = useRef(mentions) @@ -280,6 +290,28 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const fitsAtLeast = (minWidth: number) => shellWidth === null ? !compact : shellWidth >= minWidth const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH) const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') + const referenceCwd = activeLaunchWorkDir || resolvedWorkDir || '' + const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}` + const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null + const composerReferences = referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES + useEffect(() => { + let active = true + if (isMemberSession) return + setReferenceState(previous => ({ context: referenceContext, items: previous?.context === referenceContext ? previous.items : [], loading: true, error: false })) + void composerReferencesApi.list(referenceCwd || undefined).then(data => { + if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false }) + }).catch(() => { + if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true }) + }) + return () => { active = false } + }, [referenceContext, referenceCwd, isMemberSession, slashMenuOpen, fileSearchOpen]) + useEffect(() => { + setReferenceDetail(null) + setReferenceOptionId(undefined) + setFileSearchOpen(false) + setSlashMenuOpen(false) + }, [referenceContext]) + // The run location lives in the toolbar on the wide desktop composer, and it // stays there for the whole session: editable while the session is still a // draft, read-only once the first message lands. It used to jump from inside @@ -525,19 +557,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // when it is absent preserves the original behavior: with no menu in the // DOM, an outside press was ignored. isExempt: (target) => { - const menu = document.getElementById('file-search-menu') + const menu = document.getElementById(referenceMenuId) if (!menu) return true return target instanceof Node && menu.contains(target) }, }) - const allSlashCommands = useMemo( - () => appendAgentSlashCommands( - mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), - agentSlashCommands, - ), - [agentSlashCommands, slashCommands, t], - ) + const allSlashCommands = useMemo(() => { + const commands = appendAgentSlashCommands(mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), agentSlashCommands) + const names = new Set(commands.map(command => command.name.toLowerCase())) + for (const reference of composerReferences) { + const name = reference.kind === 'plugin' ? reference.id : reference.name + if (names.has(name.toLowerCase())) continue + names.add(name.toLowerCase()) + commands.push({ name, description: reference.description, kind: reference.kind }) + } + return commands + }, [agentSlashCommands, slashCommands, composerReferences, t]) const filteredCommandGroups = useMemo(() => { return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter)) @@ -545,6 +581,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const filteredCommands = filteredCommandGroups.ordered const isSlashMenuVisible = !isMemberSession && slashMenuOpen && filteredCommands.length > 0 + const isReferenceMenuVisible = !isMemberSession && fileSearchOpen const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() @@ -626,14 +663,28 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const selectSlashCommand = useCallback((command: string) => { const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length - const replacement = replaceSlashToken(input, cursorPos, command) - setComposerInput(replacement.value) + const option = allSlashCommands.find(item => item.name === command) + let nextCursor: number + const reference = (option?.kind === 'skill' || option?.kind === 'plugin') + ? composerReferences.find(item => item.kind === option.kind && (item.id === command || item.name === command)) + : undefined + if (reference) { + const mention = composerReferenceToMention(reference) + const trigger = findSlashTrigger(input, cursorPos) + const inserted = insertMentionIntoText(input, mentions, trigger?.slashPos ?? cursorPos, cursorPos, mention) + setComposerInput(inserted.text, inserted.mentions) + nextCursor = inserted.cursorPos + } else { + const replacement = replaceSlashToken(input, cursorPos, command) + setComposerInput(replacement.value) + nextCursor = replacement.cursorPos + } setSlashMenuOpen(false) requestAnimationFrame(() => { composerRef.current?.focus() - composerRef.current?.setSelectionOffsets(replacement.cursorPos) + composerRef.current?.setSelectionOffsets(nextCursor) }) - }, [input]) + }, [input, mentions, allSlashCommands, composerReferences, setComposerInput]) const replaceEmptySession = useCallback(async ( workDir: string, @@ -862,7 +913,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // Ignore key events during IME composition (e.g. Chinese input method) if (composingRef.current || event.isComposing || event.keyCode === 229) return false - // Route file search navigation keys to FileSearchMenu + // Route reference selection and directory navigation to the unified menu if (fileSearchOpen) { const key = event.key if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'Enter' || key === 'Tab' || key === 'Escape') { @@ -903,6 +954,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const selected = filteredCommands[slashSelectedIndex] if ( exactSlashCommand && + !composerReferences.some(item => item.kind === selected?.kind && (item.id === selected?.name || item.name === selected?.name)) && selected?.name.toLowerCase() === exactSlashCommand.name.toLowerCase() && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() && shouldSubmitOnEnter(event, chatSendBehavior) @@ -1113,8 +1165,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro )} {!isMemberSession && fileSearchOpen && ( - { + onSelect={(mention) => { if (atCursorPos < 0) return - const referenceName = name.split('/').filter(Boolean).pop() ?? name const tokenEnd = atCursorPos + 1 + atFilter.length - const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, { - label: isDirectory ? `${referenceName}/` : referenceName, - path, - isDirectory, - }) + const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, mention) setComposerInput(inserted.text, inserted.mentions) setFileSearchOpen(false) setAtFilter('') @@ -1169,6 +1221,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ref={slashMenuRef} id={slashMenuId} groups={filteredCommandGroups} + references={composerReferences} selectedIndex={slashSelectedIndex} itemRefs={slashItemRefs} onSelect={selectSlashCommand} @@ -1300,6 +1353,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro rootRef={composerContainerRef} value={input} mentions={mentions} + onMentionClick={setReferenceDetail} onChange={handleComposerChange} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste} @@ -1310,11 +1364,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro className="flex-1" editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]" aria={{ - role: isSlashMenuVisible ? 'combobox' : 'textbox', - 'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined, - 'aria-expanded': isSlashMenuVisible ? 'true' : undefined, - 'aria-controls': isSlashMenuVisible ? slashMenuId : undefined, - 'aria-activedescendant': isSlashMenuVisible + role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox', + 'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined, + 'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined, + 'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined, + 'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible ? getSlashCommandOptionId(slashMenuId, slashSelectedIndex) : undefined, }} @@ -1326,6 +1380,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro rootRef={composerContainerRef} value={input} mentions={mentions} + onMentionClick={setReferenceDetail} onChange={handleComposerChange} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste} @@ -1337,11 +1392,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro useCompactChrome ? 'py-1.5' : 'py-2' }`} aria={{ - role: isSlashMenuVisible ? 'combobox' : 'textbox', - 'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined, - 'aria-expanded': isSlashMenuVisible ? 'true' : undefined, - 'aria-controls': isSlashMenuVisible ? slashMenuId : undefined, - 'aria-activedescendant': isSlashMenuVisible + role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox', + 'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined, + 'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined, + 'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined, + 'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible ? getSlashCommandOptionId(slashMenuId, slashSelectedIndex) : undefined, }} @@ -1568,6 +1623,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro )} + setReferenceDetail(null)} /> ) } diff --git a/desktop/src/components/chat/ComposerReferenceDetail.test.tsx b/desktop/src/components/chat/ComposerReferenceDetail.test.tsx new file mode 100644 index 00000000..06877006 --- /dev/null +++ b/desktop/src/components/chat/ComposerReferenceDetail.test.tsx @@ -0,0 +1,11 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { ComposerReferenceDetail } from './ComposerReferenceDetail' +it('shows the selected capability description without treating it as markup or invoking a task', () => { + const close = vi.fn() + render(not markup',path:'',isDirectory:false,tokenOrdinal:0,icon:'/connectors/hyperframes.svg'}} onClose={close} />) + expect(screen.getByRole('dialog', {name:'Example'})).toBeTruthy() + expect(screen.getByText('')).toBeTruthy() + fireEvent.click(screen.getByRole('button', {name:'Close dialog'})) + expect(close).toHaveBeenCalledOnce() +}) diff --git a/desktop/src/components/chat/ComposerReferenceDetail.tsx b/desktop/src/components/chat/ComposerReferenceDetail.tsx new file mode 100644 index 00000000..8c928111 --- /dev/null +++ b/desktop/src/components/chat/ComposerReferenceDetail.tsx @@ -0,0 +1,18 @@ +import { Modal } from '@/components/ui/Modal' +import { useTranslation } from '@/i18n' +import { safeMentionIcon, type ComposerMention } from '@/lib/composerMentions' + +export function ComposerReferenceDetail({ mention, onClose }: { mention: ComposerMention | null, onClose: () => void }) { + const t = useTranslation() + const icon = safeMentionIcon(mention?.icon) + return + {mention &&
+
+ {icon ? : } + {t(mention.kind === 'plugin' ? 'chat.referencePlugins' : 'chat.referenceSkills')} +
+

{mention.description || mention.label}

+ {mention.id &&

{mention.id}

} +
} +
+} diff --git a/desktop/src/components/chat/ComposerReferenceMenu.test.tsx b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx new file mode 100644 index 00000000..e616c968 --- /dev/null +++ b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx @@ -0,0 +1,91 @@ +import { createRef } from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' +import { filesystemApi } from '@/api/filesystem' +import type { ComposerReferenceCandidate } from '@/types/composerReference' +import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu' + +vi.mock('@/api/filesystem', () => ({ filesystemApi: { browse: vi.fn(), search: vi.fn() } })) +const directory = { name: 'src', path: '/work/src', isDirectory: true } +const file = { name: 'app.ts', path: '/work/app.ts', isDirectory: false } +const references: ComposerReferenceCandidate[] = [ + { kind: 'plugin', id: 'hyperframes', name: 'hyperframes', displayName: 'HyperFrames', description: 'Video creation', source: 'plugin', modelText: 'Use HyperFrames', icon: '/connectors/hyperframes.svg' }, + { kind: 'skill', id: 'design', name: 'design', displayName: 'Design', description: 'Create interfaces', source: 'user', path: '/skills/design/SKILL.md', modelText: 'Use design' }, +] +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [directory, file] }) + vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [file] }) +}) + +it('unifies plugin, skill and file groups with structured selection and active option ids', async () => { + const ref = createRef() + const onSelect = vi.fn() + const onActiveChange = vi.fn() + render() + await screen.findByRole('option', { name: 'app.ts' }) + expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['HyperFramesVideo creation', 'DesignCreate interfaces', 'src', 'app.ts']) + expect(onActiveChange).toHaveBeenLastCalledWith('references-option-0') + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) }) + expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'plugin', id: 'hyperframes', modelText: 'Use HyperFrames', path: '', isDirectory: false })) + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })) }) + expect(screen.getByRole('option', { name: 'Design' })).toHaveAttribute('aria-selected', 'true') + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })) }) + expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'skill', id: 'design', path: '/skills/design/SKILL.md' })) + fireEvent.click(screen.getByRole('option', { name: 'app.ts' })) + expect(onSelect).toHaveBeenLastCalledWith({ label: 'app.ts', path: '/work/app.ts', isDirectory: false }) + expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAccessibleDescription('Video creation') +}) + +it('keeps directory selection separate from ArrowRight and pointer navigation and ignores IME Enter', async () => { + const ref = createRef() + const onSelect = vi.fn() + const onNavigate = vi.fn() + render() + const option = await screen.findByRole('option', { name: 'src' }) + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true })) }) + expect(onSelect).not.toHaveBeenCalled() + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) }) + expect(onNavigate).toHaveBeenLastCalledWith('src/') + expect(onSelect).not.toHaveBeenCalled() + fireEvent.click(option.querySelector('[data-navigate-directory]')!) + expect(onNavigate).toHaveBeenCalledTimes(2) + fireEvent.click(option) + expect(onSelect).toHaveBeenCalledWith({ label: 'src/', path: '/work/src', isDirectory: true }) +}) + +it('discards late query and workspace results and never selects stale files while loading', async () => { + let oldResolve!: (value: Awaited>) => void + vi.mocked(filesystemApi.search).mockImplementationOnce(() => new Promise(resolve => { oldResolve = resolve })) + const onSelect = vi.fn() + const ref = createRef() + const view = render() + view.rerender() + act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) }) + expect(onSelect).not.toHaveBeenCalled() + await screen.findByRole('option', { name: 'app.ts' }) + await act(async () => { oldResolve({ currentPath: '/old', parentPath: '/', entries: [{ name: 'old.ts', path: '/old/old.ts', isDirectory: false }] }) }) + expect(screen.queryByRole('option', { name: 'old.ts' })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: 'app.ts' })).toBeInTheDocument() +}) + +it('keeps plugin matches usable when file loading fails without exposing raw errors or remote icons', async () => { + vi.mocked(filesystemApi.browse).mockRejectedValue(new Error('secret-server-error')) + const onSelect = vi.fn() + render() + await waitFor(() => expect(screen.getAllByRole('alert')).toHaveLength(2)) + expect(document.body.textContent).not.toContain('secret-') + expect(document.querySelector('img')).toBeNull() + fireEvent.click(screen.getByRole('option', { name: 'HyperFrames' })) + expect(onSelect).toHaveBeenCalled() +}) + +it('browses explicit path filters and reports no active descendant for empty results', async () => { + vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work/src', parentPath: '/work', entries: [] }) + const onActiveChange = vi.fn() + render() + await waitFor(() => expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true })) + expect(screen.queryAllByRole('option')).toHaveLength(0) + expect(onActiveChange).toHaveBeenLastCalledWith(undefined) +}) diff --git a/desktop/src/components/chat/ComposerReferenceMenu.tsx b/desktop/src/components/chat/ComposerReferenceMenu.tsx new file mode 100644 index 00000000..09e95a4d --- /dev/null +++ b/desktop/src/components/chat/ComposerReferenceMenu.tsx @@ -0,0 +1,146 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { ChevronRight, File, Folder, Package, Sparkles } from 'lucide-react' +import { ApiError } from '@/api/client' +import { filesystemApi } from '@/api/filesystem' +import { useTranslation } from '@/i18n' +import { safeMentionIcon, type NewComposerMention } from '@/lib/composerMentions' +import type { ComposerReferenceCandidate } from '@/types/composerReference' + +type FileEntry = { name: string, path: string, isDirectory: boolean, relativePath?: string } +type Row = { key: string, label: string, description: string, mention: NewComposerMention, file?: FileEntry } +export type ComposerReferenceMenuHandle = { handleKeyDown(event: KeyboardEvent): void } +type Props = { + id: string + cwd: string + filter?: string + compact?: boolean + references: ComposerReferenceCandidate[] + referencesLoading?: boolean + referencesError?: string | boolean | null + onSelect(mention: NewComposerMention): void + onNavigate?(relativePath: string): void + onActiveChange?(optionId: string | undefined): void +} + +export function getComposerReferenceOptionId(id: string, index: number): string { return `${id}-option-${index}` } + +export const ComposerReferenceMenu = forwardRef(function ComposerReferenceMenu({ + id, cwd, filter = '', compact = false, references, referencesLoading = false, referencesError, + onSelect, onNavigate, onActiveChange, +}, ref) { + const t = useTranslation() + const [manualPath, setManualPath] = useState<{ cwd: string, filter: string, path: string } | null>(null) + const override = manualPath?.cwd === cwd && manualPath.filter === filter ? manualPath.path : undefined + const queryKey = `${cwd}\0${filter}\0${override ?? ''}` + const [result, setResult] = useState<{ key: string, entries: FileEntry[], root: string, current: string, error?: 'denied' | 'failed' } | null>(null) + const [selection, setSelection] = useState<{ query: string, key: string } | null>(null) + const listRef = useRef(null) + const rootRef = useRef({ cwd, path: cwd }) + if (rootRef.current.cwd !== cwd) rootRef.current = { cwd, path: cwd } + const currentResult = result?.key === queryKey ? result : null + const loading = currentResult === null + + useEffect(() => { + let active = true + const base = (cwd || rootRef.current.path).replace(/[\\/]+$/, '') + const directoryQuery = filter.replace(/\\/g, '/').trim() + const browsing = directoryQuery.endsWith('/') + const path = override ?? (browsing && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base) + const search = override || browsing ? '' : directoryQuery + const request = search ? filesystemApi.search(search, path) : filesystemApi.browse(path, { includeFiles: true }) + void request.then(data => { + if (!active) return + if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath } + setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path }) + }, error => { + if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' }) + }) + return () => { active = false } + }, [cwd, filter, override, queryKey]) + + const groups = useMemo(() => { + const query = filter.trim().toLocaleLowerCase() + const matches = references.filter(item => !query.endsWith('/') && query.split(/\s+/).every(word => `${item.displayName} ${item.name} ${item.description}`.toLocaleLowerCase().includes(word))) + const referenceRow = (item: ComposerReferenceCandidate): Row => ({ + key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description, + mention: { kind: item.kind, id: item.id, label: item.displayName || item.name, path: item.path ?? '', isDirectory: false, description: item.description, icon: safeMentionIcon(item.icon), modelText: item.modelText }, + }) + const files: Row[] = (currentResult?.entries ?? []).map(entry => { + const base = (cwd || currentResult?.root || '').replace(/\\/g, '/').replace(/\/+$/, '') + const path = entry.path.replace(/\\/g, '/') + const relative = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : entry.relativePath ?? entry.name + const name = entry.name.split(/[\\/]/).filter(Boolean).at(-1) ?? entry.name + return { key: `file:${entry.path}`, label: name, description: relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '', file: { ...entry, relativePath: relative }, mention: { label: entry.isDirectory ? `${name}/` : name, path: entry.path, isDirectory: entry.isDirectory } } + }) + return [ + { kind: 'plugins', label: t('chat.referencePlugins'), rows: matches.filter(item => item.kind === 'plugin').map(referenceRow) }, + { kind: 'skills', label: t('chat.referenceSkills'), rows: matches.filter(item => item.kind === 'skill').map(referenceRow) }, + { kind: 'files', label: t('chat.referenceFiles'), rows: files }, + ] + }, [references, filter, currentResult, cwd, t]) + const rows = groups.flatMap(group => group.rows) + const foundIndex = selection?.query === queryKey ? rows.findIndex(row => row.key === selection.key) : -1 + const activeIndex = rows.length ? Math.max(0, foundIndex) : -1 + const activeOptionId = activeIndex < 0 ? undefined : getComposerReferenceOptionId(id, activeIndex) + + useEffect(() => { onActiveChange?.(activeOptionId) }, [activeOptionId, onActiveChange]) + useEffect(() => { + if (activeOptionId) listRef.current?.ownerDocument.getElementById(activeOptionId)?.scrollIntoView?.({ block: 'nearest' }) + }, [activeOptionId]) + const highlight = useCallback((row: Row) => setSelection({ query: queryKey, key: row.key }), [queryKey]) + const navigate = useCallback((row: Row) => { + if (!row.file?.isDirectory) return + const relative = `${(row.file.relativePath ?? row.file.name).replace(/\/+$/, '')}/` + if (onNavigate) onNavigate(relative) + else setManualPath({ cwd, filter, path: row.file.path }) + }, [cwd, filter, onNavigate]) + useImperativeHandle(ref, () => ({ handleKeyDown(event) { + if (event.isComposing || event.keyCode === 229) return + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + if (rows.length) highlight(rows[(Math.max(activeIndex, 0) + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length]!) + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + if (rows[activeIndex]) onSelect(rows[activeIndex]!.mention) + } else if (event.key === 'ArrowRight' && rows[activeIndex]?.file?.isDirectory) { + event.preventDefault() + navigate(rows[activeIndex]!) + } + } }), [activeIndex, rows, highlight, navigate, onSelect]) + + let offset = 0 + return ( +
event.preventDefault()}> +
+ {groups.map(group => { + const start = offset + offset += group.rows.length + if (!group.rows.length) return null + return
+
{group.label}
+ {group.rows.map((row, position) => { + const index = start + position + const Icon = row.file ? (row.file.isDirectory ? Folder : File) : row.mention.kind === 'plugin' ? Package : Sparkles + return
highlight(row)} onClick={event => { + if ((event.target as Element).closest('[data-navigate-directory]')) navigate(row) + else onSelect(row.mention) + }} + className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${activeIndex === index ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}> + {row.mention.icon ? :
+ })} +
+ })} + {loading || referencesLoading ?
{t('fileSearch.searching')}
: null} + {currentResult?.error ?
{t(currentResult.error === 'denied' ? 'fileSearch.accessDenied' : 'fileSearch.loadFailed')}
: null} + {referencesError ?
{t('chat.referencesLoadFailed')}
: null} + {!rows.length && !loading && !referencesLoading && !currentResult?.error && !referencesError ?
{t('chat.referencesEmpty')}
: null} +
+ {!compact ?
↑↓{t('fileSearch.navigate')}Enter / Tab{t('fileSearch.select')}→{t('fileSearch.open')}Esc{t('fileSearch.close')}
: null} +
+ ) +}) diff --git a/desktop/src/components/chat/FileSearchMenu.test.tsx b/desktop/src/components/chat/FileSearchMenu.test.tsx deleted file mode 100644 index e44f4569..00000000 --- a/desktop/src/components/chat/FileSearchMenu.test.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' -import { FileSearchMenu } from './FileSearchMenu' -import { ApiError } from '../../api/client' -import { filesystemApi } from '../../api/filesystem' -import { useSettingsStore } from '../../stores/settingsStore' - -vi.mock('../../api/filesystem', () => ({ - filesystemApi: { - browse: vi.fn(), - search: vi.fn(), - }, -})) - -describe('FileSearchMenu', () => { - beforeEach(() => { - vi.clearAllMocks() - useSettingsStore.setState({ locale: 'en' }) - }) - - it('shows an explicit error when directory browsing is denied', async () => { - vi.mocked(filesystemApi.browse).mockRejectedValueOnce( - new ApiError(403, { error: 'Access denied: path outside allowed directory' }), - ) - - render( - {}} - />, - ) - - expect(await screen.findByText('Cannot access this directory')).toBeInTheDocument() - expect(screen.queryByText('No files in this directory')).not.toBeInTheDocument() - }) - - it('renders returned files when browsing succeeds', async () => { - vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ - currentPath: '/tmp', - parentPath: '/', - entries: [ - { name: 'preview.png', path: '/tmp/preview.png', isDirectory: false }, - ], - }) - - render( - {}} - />, - ) - - await waitFor(() => { - expect(screen.getByText('preview.png')).toBeInTheDocument() - }) - }) - - it('selects directories from search results and keeps a separate drill-in action', async () => { - const onSelect = vi.fn() - const onNavigate = vi.fn() - vi.mocked(filesystemApi.search).mockResolvedValueOnce({ - currentPath: '/repo', - parentPath: '/', - query: 'backend', - entries: [ - { name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true }, - ], - }) - vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ - currentPath: '/repo/backend', - parentPath: '/repo', - entries: [ - { name: 'src', path: '/repo/backend/src', isDirectory: true }, - ], - }) - vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ - currentPath: '/repo/backend/src', - parentPath: '/repo/backend', - entries: [ - { name: 'commands', path: '/repo/backend/src/commands', isDirectory: true }, - ], - }) - - render( - , - ) - - fireEvent.click(await screen.findByText('backend/')) - - expect(onSelect).toHaveBeenCalledWith('/repo/backend', 'backend', true) - expect(onNavigate).not.toHaveBeenCalled() - - fireEvent.click(screen.getByLabelText('Open folder')) - - expect(onNavigate).toHaveBeenCalledWith('backend/') - await waitFor(() => { - expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true }) - }) - - fireEvent.click(await screen.findByText('src')) - expect(onSelect).toHaveBeenLastCalledWith('/repo/backend/src', 'backend/src', true) - - fireEvent.click(screen.getByLabelText('Open folder')) - expect(onNavigate).toHaveBeenLastCalledWith('backend/src/') - await waitFor(() => { - expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend/src', { includeFiles: true }) - }) - }) - - it('passes nested relative file paths when selecting a file', async () => { - const onSelect = vi.fn() - vi.mocked(filesystemApi.search).mockResolvedValueOnce({ - currentPath: '/repo', - parentPath: '/', - query: 'pictactic', - entries: [ - { name: 'pictactic', path: '/repo/backend/src/pictactic', relativePath: 'backend/src/pictactic', isDirectory: false }, - ], - }) - - render( - , - ) - - fireEvent.click(await screen.findByText('backend/src/pictactic')) - - expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic', false) - }) - - it('renders search results as insertable paths instead of repeated basenames', async () => { - vi.mocked(filesystemApi.search).mockResolvedValueOnce({ - currentPath: '/repo', - parentPath: '/', - query: 'src', - entries: [ - { name: 'src', path: '/repo/src', relativePath: 'src', isDirectory: true }, - { name: 'hooks', path: '/repo/src/hooks', relativePath: 'src/hooks', isDirectory: true }, - { name: 'src', path: '/repo/desktop/src', relativePath: 'desktop/src', isDirectory: true }, - ], - }) - - render( - {}} - />, - ) - - expect(await screen.findByText('src/')).toBeInTheDocument() - expect(screen.getByText('src/hooks/')).toBeInTheDocument() - expect(screen.getByText('desktop/src/')).toBeInTheDocument() - }) - - it('uses the resolved home root for typed folder filters when no workspace is selected', async () => { - vi.mocked(filesystemApi.search).mockResolvedValueOnce({ - currentPath: '/Users/nanmi', - parentPath: '/', - query: 'workspace', - entries: [ - { name: 'workspace', path: '/Users/nanmi/workspace', relativePath: 'workspace', isDirectory: true }, - ], - }) - vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ - currentPath: '/Users/nanmi/workspace', - parentPath: '/Users/nanmi', - entries: [], - }) - - const { rerender } = render( - {}} - />, - ) - - expect(await screen.findByText('workspace/')).toBeInTheDocument() - - rerender( - {}} - />, - ) - - await waitFor(() => { - expect(filesystemApi.browse).toHaveBeenCalledWith('/Users/nanmi/workspace', { includeFiles: true }) - }) - }) -}) diff --git a/desktop/src/components/chat/FileSearchMenu.tsx b/desktop/src/components/chat/FileSearchMenu.tsx deleted file mode 100644 index 0008083d..00000000 --- a/desktop/src/components/chat/FileSearchMenu.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react' -import { ApiError } from '../../api/client' -import { filesystemApi } from '../../api/filesystem' -import { useTranslation } from '../../i18n' -import type { TranslationKey } from '../../i18n' -import { IconButton } from '@/components/ui/IconButton' - -type DirEntry = { - name: string - path: string - isDirectory: boolean - relativePath?: string -} - -export type FileSearchMenuHandle = { - handleKeyDown: (e: KeyboardEvent) => void -} - -type Props = { - cwd: string - filter?: string - compact?: boolean - onSelect: (path: string, relativePath: string, isDirectory: boolean) => void - onNavigate?: (relativePath: string) => void -} - -export const FileSearchMenu = forwardRef(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => { - const t = useTranslation() - const [entries, setEntries] = useState([]) - const [errorMessage, setErrorMessage] = useState(null) - const [errorKey, setErrorKey] = useState(null) - const [currentPath, setCurrentPath] = useState(cwd) - const [isSearchMode, setIsSearchMode] = useState(false) - const [loading, setLoading] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(0) - const [rootPath, setRootPath] = useState(cwd) - const listRef = useRef(null) - const currentPathRef = useRef(cwd) - const rootPathRef = useRef(cwd) - - const getErrorState = (error: unknown): { errorKey: TranslationKey | null; errorMessage: string | null } => { - if (error instanceof ApiError) { - if (error.status === 403) { - return { errorKey: 'fileSearch.accessDenied', errorMessage: null } - } - - const apiMessage = - typeof error.body === 'string' - ? error.body - : typeof error.body === 'object' && - error.body !== null && - 'error' in error.body && - typeof error.body.error === 'string' - ? error.body.error - : null - - if (apiMessage) { - return { errorKey: null, errorMessage: apiMessage } - } - } - - return { errorKey: 'fileSearch.loadFailed', errorMessage: null } - } - - const getRelativePath = useCallback((entry: DirEntry) => { - const basePath = (cwd || rootPath).replace(/\/+$/, '') - if (entry.path.startsWith(`${basePath}/`)) return entry.path.slice(basePath.length + 1) - if (entry.relativePath) return entry.relativePath - return entry.name - }, [cwd, rootPath]) - - const getDisplayPath = useCallback((entry: DirEntry) => { - const relativePath = getRelativePath(entry).replace(/\\/g, '/') - if (!entry.isDirectory) return relativePath - return `${relativePath.replace(/\/+$/, '')}/` - }, [getRelativePath]) - - const selectEntry = useCallback((entry: DirEntry) => { - onSelect(entry.path, getRelativePath(entry), entry.isDirectory) - }, [getRelativePath, onSelect]) - - const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => { - const trimmed = rawFilter.trim() - const basePath = (cwd || rootPathRef.current).replace(/\/+$/, '') - if (!trimmed) return { navigateTo: basePath, searchQuery: '' } - if (trimmed.endsWith('/')) { - if (!basePath) return { navigateTo: '', searchQuery: trimmed.replace(/\/+$/, '') } - return { navigateTo: `${basePath}/${trimmed.replace(/\/+$/, '')}`, searchQuery: '' } - } - return { navigateTo: basePath, searchQuery: trimmed } - } - - // Load directory entries - const loadDir = useCallback(async (dirPath: string, searchQuery: string) => { - setLoading(true) - setErrorMessage(null) - setErrorKey(null) - // Only update currentPath if actually navigating to a different directory - if (dirPath !== currentPathRef.current) { - setCurrentPath(dirPath) - currentPathRef.current = dirPath - } - try { - if (searchQuery) { - setIsSearchMode(true) - const result = await filesystemApi.search(searchQuery, dirPath) - setCurrentPath(result.currentPath) - currentPathRef.current = result.currentPath - if (!cwd) { - setRootPath(result.currentPath) - rootPathRef.current = result.currentPath - } - setEntries(result.entries) - } else { - setIsSearchMode(false) - const result = await filesystemApi.browse(dirPath, { includeFiles: true }) - setCurrentPath(result.currentPath) - currentPathRef.current = result.currentPath - if (!cwd) { - setRootPath(result.currentPath) - rootPathRef.current = result.currentPath - } - setEntries(result.entries) - } - setSelectedIndex(0) - } catch (error) { - setEntries([]) - const nextError = getErrorState(error) - setErrorKey(nextError.errorKey) - setErrorMessage(nextError.errorMessage) - } - setLoading(false) - }, [cwd]) - - const navigateEntry = useCallback((entry: DirEntry) => { - if (!entry.isDirectory) return - const relativePath = `${getRelativePath(entry).replace(/\/+$/, '')}/` - void loadDir(entry.path, '') - onNavigate?.(relativePath) - }, [getRelativePath, loadDir, onNavigate]) - - // Keep the explicit workspace root stable when the host session changes. - useEffect(() => { - currentPathRef.current = cwd - rootPathRef.current = cwd - setRootPath(cwd) - setCurrentPath(cwd) - }, [cwd]) - - // Initial load: parse filter path and navigate accordingly - useEffect(() => { - const { navigateTo, searchQuery } = parseFilter(filter) - void loadDir(navigateTo, searchQuery) - }, [cwd, filter, loadDir]) - - // Keyboard navigation handler exposed via ref - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, entries.length - 1)) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) - return - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault() - const selected = entries[selectedIndex] - if (selected) { - selectEntry(selected) - } - return - } - if (e.key === 'ArrowRight') { - const selected = entries[selectedIndex] - if (selected?.isDirectory) { - e.preventDefault() - navigateEntry(selected) - } - return - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, selectedIndex, selectEntry, navigateEntry]) - - useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) - - // Scroll selected into view - useEffect(() => { - const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`) as HTMLButtonElement | null - el?.scrollIntoView({ block: 'nearest' }) - }, [selectedIndex]) - - // Build breadcrumb segments from current path relative to cwd - const breadcrumbs: string[] = [] - if (currentPath !== cwd && currentPath.startsWith(cwd)) { - const rel = currentPath.slice(cwd.length).replace(/^\//, '') - if (rel) breadcrumbs.push(...rel.split('/')) - } - - const renderEntry = (entry: DirEntry, index: number) => { - const relativePath = getRelativePath(entry) - const displayPath = getDisplayPath(entry) - const parentPath = relativePath.split('/').slice(0, -1).join('/') - const selected = selectedIndex === index - return ( -
setSelectedIndex(index)} - > - - {entry.isDirectory ? ( - { - event.stopPropagation() - navigateEntry(entry) - }} - /> - ) : null} -
- ) - } - - return ( -
e.stopPropagation()} - > - {/* Header with path */} -
- folder_open - {cwd.split('/').pop() || cwd} - {breadcrumbs.map((seg, i) => ( - - / - {seg} - - ))} - {isSearchMode && filter ? ( - @{filter} - ) : null} - {loading && ( - progress_activity - )} -
- - {/* File list */} -
- {loading && entries.length === 0 ? ( -
{t('fileSearch.searching')}
- ) : (errorKey || errorMessage) ? ( -
- {errorKey ? t(errorKey) : errorMessage} -
- ) : entries.length === 0 ? ( -
- {filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')} -
- ) : ( - <> - {entries.map(renderEntry)} - - )} -
- - {/* Footer hint */} - {!compact ? ( -
- ↑↓ - {t('fileSearch.navigate')} - Enter - {t('fileSearch.select')} - → - {t('fileSearch.open')} - Esc - {t('fileSearch.close')} -
- ) : null} -
- ) -}) - -FileSearchMenu.displayName = 'FileSearchMenu' diff --git a/desktop/src/components/chat/MentionComposer.tsx b/desktop/src/components/chat/MentionComposer.tsx index c3127319..faf624db 100644 --- a/desktop/src/components/chat/MentionComposer.tsx +++ b/desktop/src/components/chat/MentionComposer.tsx @@ -44,7 +44,7 @@ export type MentionComposerHandle = { setSelectionOffsets: (start: number, end?: number) => void /** * Content for the model: text with each mention pill serialized to - * `@"absolute path"`. Read from the live document, so literal text that + * file paths or explicit skill/plugin requests. Read from the live document, so literal text that * merely looks like a pill's token is never rewritten. */ getModelContent: () => string @@ -56,6 +56,7 @@ export type MentionComposerProps = { onChange: (text: string, mentions: ComposerMention[]) => void onKeyDown?: (event: KeyboardEvent) => boolean onPaste?: (event: ClipboardEvent) => boolean + onMentionClick?: (mention: ComposerMention) => void onCompositionStart?: () => void onCompositionEnd?: () => void placeholder?: string @@ -149,7 +150,19 @@ export const MentionComposer = forwardRef propsRef.current.onKeyDown?.(event) ?? false, + handleKeyDown: (_view, event) => { + const chip = event.target instanceof HTMLElement ? event.target.closest('[data-mention-kind]') : null + if (chip && (event.key === 'Enter' || event.key === ' ')) { + const mention = propsRef.current.mentions.find(item => item.kind === chip.dataset.mentionKind && item.id === chip.dataset.mentionId) + if (mention) { event.preventDefault(); propsRef.current.onMentionClick?.(mention); return true } + } + return propsRef.current.onKeyDown?.(event) ?? false + }, + handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { + if (!direct || !['skill', 'plugin'].includes(node.attrs.kind)) return false + propsRef.current.onMentionClick?.({ ...node.attrs, tokenOrdinal: 0 } as ComposerMention) + return true + }, handlePaste: (editorView, event, _slice) => { if (propsRef.current.onPaste?.(event)) return true diff --git a/desktop/src/components/chat/SlashCommandMenu.test.tsx b/desktop/src/components/chat/SlashCommandMenu.test.tsx index f523f54c..c0901d69 100644 --- a/desktop/src/components/chat/SlashCommandMenu.test.tsx +++ b/desktop/src/components/chat/SlashCommandMenu.test.tsx @@ -48,3 +48,25 @@ describe('SlashCommandMenu', () => { expect(onSelect).toHaveBeenCalledWith('project-audit') }) }) + +it('uses safe skill branding and keeps the description beside its name without changing selection values', () => { + const onSelect = vi.fn() + render() + const option = screen.getByRole('option') + expect(option.querySelector('img')).toHaveAttribute('src', '/connectors/hyperframes.svg') + expect(screen.getByText('Make a video')).not.toHaveClass('text-right') + fireEvent.click(option) + expect(onSelect).toHaveBeenCalledWith('video') +}) + +it('uses the same command-plugin-skill order for option ids and keyboard references', () => { + const onSelect = vi.fn() + const itemRefs = { current: [] as (HTMLElement | null)[] } + render() + const options = screen.getAllByRole('option') + expect(options.map(option => option.id)).toEqual(['mixed-slash-option-0', 'mixed-slash-option-1', 'mixed-slash-option-2']) + expect(screen.getByRole('option', { name: 'Video plugin' })).toHaveAttribute('aria-selected', 'true') + fireEvent.click(options[1]!) + expect(onSelect).toHaveBeenCalledWith('plugin:video') + expect(itemRefs.current[2]).toBe(options[2]) +}) diff --git a/desktop/src/components/chat/SlashCommandMenu.tsx b/desktop/src/components/chat/SlashCommandMenu.tsx index c713d6bb..c94a523a 100644 --- a/desktop/src/components/chat/SlashCommandMenu.tsx +++ b/desktop/src/components/chat/SlashCommandMenu.tsx @@ -26,6 +26,8 @@ import { import { useTranslation } from '@/i18n' import type { SlashCommandGroups } from './composerUtils' import type { SlashCommandSource } from '@/types/slashCommand' +import type { ComposerReferenceCandidate } from '@/types/composerReference' +import { safeMentionIcon } from '@/lib/composerMentions' const SYSTEM_SLASH_COMMAND_ICONS: Record = { agent: Bot, @@ -82,6 +84,7 @@ type SlashCommandMenuProps = { onSelect: (commandName: string) => void onHighlight: (index: number) => void showKeyboardHints: boolean + references?: ComposerReferenceCandidate[] } export const SlashCommandMenu = forwardRef( @@ -94,6 +97,7 @@ export const SlashCommandMenu = forwardRef