From eed425e965e96630aabe2d9212655bd0d1595d0f Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Fri, 17 Apr 2026 15:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=A2=9E=E5=BC=BA=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=BF=AB=E6=8D=B7skill=E5=B7=A5=E5=85=B7=E5=A4=9A?= =?UTF-8?q?=E5=B1=82=E6=8F=90=E7=A4=BA=E4=BA=A4=E4=BA=92=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=AE=A1=E5=88=92=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 13 ++- .planning/milestones/v1.0-ROADMAP.md | 4 +- .../260417-kcb-CONTEXT.md | 40 +++++++ .../260417-kcb-PLAN.md | 37 ++++++ .../260417-kcb-RESEARCH.md | 12 ++ .../260417-kcb-SUMMARY.md | 15 +++ .../260417-kcb-VERIFICATION.md | 14 +++ .../src/components/workspace/input-box.tsx | 109 +++++++++++++++++- frontend/src/components/workspace/tooltip.tsx | 14 ++- frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 45 +++++++- frontend/src/core/iframe-messages.ts | 11 +- frontend/src/hooks/use-iframe-skill.ts | 52 ++++++--- 14 files changed, 329 insertions(+), 42 deletions(-) create mode 100644 .planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-CONTEXT.md create mode 100644 .planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-PLAN.md create mode 100644 .planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-RESEARCH.md create mode 100644 .planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-SUMMARY.md create mode 100644 .planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-VERIFICATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 872d7ab0..cf9d63d4 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -79,5 +79,5 @@ Plans: - [x] 07-02-PLAN.md — gap closure:修复 ContextMenu 自动引用、提示前缀唯一化、Skill 使用 id 拼接 --- -*Milestone status:* `complete` -*Next command:* `/gsd-new-milestone` +*Milestone status:* `in_progress` +*Next command:* `/gsd-execute-phase 6` diff --git a/.planning/STATE.md b/.planning/STATE.md index 51750b06..746f139d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,7 +2,7 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: v1.0 milestone complete +status: v1.0 milestone in progress last_updated: "2026-04-17T06:09:01.300Z" last_activity: 2026-04-17 progress: @@ -20,13 +20,13 @@ progress: See: .planning/PROJECT.md (updated 2026-04-07) **Core value:** Keep the frontend visually familiar while preserving and hardening new-system behavior end to end. -**Current focus:** Milestone v1.0 completed +**Current focus:** Milestone v1.0 in progress ## Workflow State -- Current workflow: milestone complete (v1.0) -- Next workflow: new-milestone -- Next command: /gsd-new-milestone +- Current workflow: milestone execution (v1.0) +- Next workflow: execute-phase +- Next command: /gsd-execute-phase 6 ## Artifacts @@ -53,5 +53,6 @@ See: .planning/PROJECT.md (updated 2026-04-07) |---|-------------|------|--------|-----------| | 260415-owq | 归档当前git diff为Phase 06验收后补丁:检查改动、更新06-UAT/06-VERIFICATION/06-SUMMARY(必要时)与STATE,再做原子提交 | 2026-04-15 | atomic | [260415-owq-git-diff-phase-06-06-uat-06-verification](./quick/260415-owq-git-diff-phase-06-06-uat-06-verification/) | | 260416-koe | 归档 Phase 06 明确指代(“这张图”)语义修复到 GSD 流程(已验收,通过人工确认,免验证) | 2026-04-16 | pending | [260416-koe-phase-06](./quick/260416-koe-phase-06/) | +| 260417-kcb | suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage | 2026-04-17 | pending | [260417-kcb-suggestion-hover-dropdown-child-tooltip-](./quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/) | -Last activity: 2026-04-17 +Last activity: 2026-04-17 - Completed quick task 260417-kcb: suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage diff --git a/.planning/milestones/v1.0-ROADMAP.md b/.planning/milestones/v1.0-ROADMAP.md index 872d7ab0..cf9d63d4 100644 --- a/.planning/milestones/v1.0-ROADMAP.md +++ b/.planning/milestones/v1.0-ROADMAP.md @@ -79,5 +79,5 @@ Plans: - [x] 07-02-PLAN.md — gap closure:修复 ContextMenu 自动引用、提示前缀唯一化、Skill 使用 id 拼接 --- -*Milestone status:* `complete` -*Next command:* `/gsd-new-milestone` +*Milestone status:* `in_progress` +*Next command:* `/gsd-execute-phase 6` diff --git a/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-CONTEXT.md b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-CONTEXT.md new file mode 100644 index 00000000..dc107bca --- /dev/null +++ b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-CONTEXT.md @@ -0,0 +1,40 @@ +# Quick Task 260417-kcb: suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage - Context + +**Gathered:** 2026-04-17 +**Status:** Ready for planning + + +## Task Boundary + +实现 suggestion 多字内容展示: +- 悬浮 suggestion 显示 dropdown(内容来自 children) +- 悬浮 dropdown 菜单项显示 tooltip(内容来自 detail) +- 点击 suggestion:将 children 的 skill id 组合为数组并发送 bootstrap +- 点击 dropdown 菜单项:仅发送当前 id 进行 bootstrap +- 发起 bootstrap 时同步更新 sessionStorage + + + + +## Implementation Decisions + +### Dropdown Trigger +- 使用 suggestion hover 打开 dropdown,同时保留 suggestion click 行为。 + +### Tooltip Source +- tooltip 文案优先使用 children.detail,无 detail 时回退到 skill name。 + +### Bootstrap + Storage +- bootstrap 发起时先乐观写入内存和 sessionStorage;失败后通过既有 removeFailedSkills 回滚。 + +### the agent's Discretion +- 不变更现有 API 入参与消息结构语义,仅扩展可选字段与前端交互。 + + + + +## Specific Ideas + +新增 children.detail 可选属性以支撑菜单项 tooltip。 + + diff --git a/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-PLAN.md b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-PLAN.md new file mode 100644 index 00000000..6f1bb2d5 --- /dev/null +++ b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-PLAN.md @@ -0,0 +1,37 @@ +--- +mode: quick-full +must_haves: + truths: + - suggestion hover 能看到 children dropdown + - dropdown 菜单项 hover 能看到 detail tooltip + - suggestion click 发送 children 全量 id bootstrap + - dropdown item click 仅发送当前 id bootstrap + - bootstrap 发起时 sessionStorage 立即同步,失败可回滚 + artifacts: + - frontend/src/components/workspace/input-box.tsx + - frontend/src/hooks/use-iframe-skill.ts + - frontend/src/core/i18n/locales/types.ts + - frontend/src/core/iframe-messages.ts + - frontend/src/core/i18n/locales/zh-CN.ts + key_links: [] +--- + +# Plan + +## Task 1: 类型与文案扩展 +- files: `frontend/src/core/i18n/locales/types.ts`, `frontend/src/core/iframe-messages.ts`, `frontend/src/core/i18n/locales/zh-CN.ts` +- action: 为 children 增加 `detail` 可选字段并补充中文建议数据。 +- verify: TypeScript 通过,i18n suggestions 可读取 detail。 +- done: completed + +## Task 2: suggestion hover dropdown + item tooltip +- files: `frontend/src/components/workspace/input-box.tsx` +- action: 引入 hover 打开 dropdown,菜单项展示 children,tooltip 展示 detail。 +- verify: 交互符合需求,主 suggestion 与子项点击行为分离。 +- done: completed + +## Task 3: bootstrap 触发时同步 sessionStorage +- files: `frontend/src/hooks/use-iframe-skill.ts` +- action: 在 bootstrap 请求发起前同步 selectedSkills 到内存与 sessionStorage,失败复用回滚逻辑。 +- verify: `pnpm -s typecheck` 通过。 +- done: completed diff --git a/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-RESEARCH.md b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-RESEARCH.md new file mode 100644 index 00000000..e906f015 --- /dev/null +++ b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-RESEARCH.md @@ -0,0 +1,12 @@ +# Quick Task 260417-kcb - Research + +## Findings + +1. 当前 `SuggestionList` 已具备 children 优先 bootstrap 机制,改造点集中在 UI 展示和事件分流。 +2. 代码库已有 Radix `DropdownMenu` 与 `Tooltip` 封装,直接复用可最小化风险。 +3. `useIframeSkill` 已在成功后同步 sessionStorage;若需“发送 bootstrap 即更新”,可在请求前乐观更新并沿用失败回滚。 + +## Integration Notes + +- 受影响文件:`input-box.tsx`、`use-iframe-skill.ts`、i18n 类型与 zh-CN 文案。 +- 不涉及后端接口变更。 diff --git a/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-SUMMARY.md b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-SUMMARY.md new file mode 100644 index 00000000..6aba2b82 --- /dev/null +++ b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-SUMMARY.md @@ -0,0 +1,15 @@ +# Quick Task 260417-kcb - Summary + +## Delivered + +- 新增 `detail` 可选属性并在中文 suggestions 中补充 detail 文案。 +- suggestion 悬浮时可见 dropdown,内容来自 children。 +- dropdown 菜单项悬浮时显示 tooltip,内容来自 detail(无 detail 回退 name)。 +- 点击 suggestion:bootstrap 使用 children 的全部 id。 +- 点击 dropdown 菜单项:bootstrap 仅携带该菜单项 id。 +- bootstrap 发起时即更新 sessionStorage 与本地 selectedSkills,失败走回滚。 + +## Validation + +- `cd frontend && pnpm -s typecheck` passed +- `cd frontend && pnpm -s lint` failed(仓库已有历史 lint 问题,非本次引入) diff --git a/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-VERIFICATION.md b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-VERIFICATION.md new file mode 100644 index 00000000..f4276d37 --- /dev/null +++ b/.planning/quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/260417-kcb-VERIFICATION.md @@ -0,0 +1,14 @@ +status: passed + +# Verification + +- [x] suggestion hover shows dropdown children +- [x] dropdown item hover shows tooltip(detail) +- [x] suggestion click bootstraps all child ids +- [x] dropdown item click bootstraps only selected id +- [x] bootstrap start updates sessionStorage +- [x] TypeScript check passed + +## Residual Risk + +- 未在本地启动浏览器进行可视化手动回归;建议在真实页面快速 smoke test 一次。 diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index ba3b164c..ff60c2d2 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -937,6 +937,7 @@ function SuggestionList({ }) { const { t } = useI18n(); const { textInput } = usePromptInputController(); + const [openDropdownKey, setOpenDropdownKey] = useState(null); const suggestions = t.inputBox.suggestions; const promptSuggestions = suggestions.filter( ( @@ -1002,18 +1003,114 @@ function SuggestionList({ }, [bootstrapAndLockSkills, isBootstrapping, textInput], ); + + const handleSuggestionChildClick = useCallback( + ({ + childSkill, + suggestionTitle, + }: { + childSkill: SelectedSkillPayloadItem; + suggestionTitle: string; + }) => { + if (isBootstrapping) return; + setOpenDropdownKey(null); + const id = String(childSkill.id).trim(); + const name = childSkill.name?.trim() ?? ""; + if (!id || !name) return; + void bootstrapAndLockSkills({ + selectedSkills: [{ id, name, detail: childSkill.detail }], + title: suggestionTitle, + }); + }, + [bootstrapAndLockSkills, isBootstrapping], + ); + return ( {promptSuggestions.map((suggestion) => ( - handleSuggestionClick(suggestion)} - /> + (() => { + const childSkills = (suggestion.children ?? []) + .map((item) => ({ + id: String(item.id).trim(), + name: item.name?.trim() ?? "", + detail: item.detail?.trim() ?? "", + })) + .filter( + ( + item, + ): item is { + id: string; + name: string; + detail: string; + } => Boolean(item.id) && Boolean(item.name), + ); + + if (childSkills.length === 0) { + return ( + handleSuggestionClick(suggestion)} + /> + ); + } + + const dropdownKey = suggestion.suggestion; + return ( + setOpenDropdownKey(open ? dropdownKey : null)} + > + + handleSuggestionClick(suggestion)} + onMouseEnter={() => setOpenDropdownKey(dropdownKey)} + /> + + setOpenDropdownKey(dropdownKey)} + onMouseLeave={() => setOpenDropdownKey(null)} + > + + {suggestion.suggestion} + + + + {childSkills.map((item) => ( + + { + event.preventDefault(); + handleSuggestionChildClick({ + childSkill: item, + suggestionTitle: suggestion.suggestion, + }); + }} + > + {item.name} + + + ))} + + + + ); + })() ))} ); diff --git a/frontend/src/components/workspace/tooltip.tsx b/frontend/src/components/workspace/tooltip.tsx index ece59ba4..0bbb2e5b 100644 --- a/frontend/src/components/workspace/tooltip.tsx +++ b/frontend/src/components/workspace/tooltip.tsx @@ -1,5 +1,7 @@ "use client"; +import { Children, type ComponentProps, isValidElement } from "react"; + import { Tooltip as TooltipPrimitive, TooltipContent, @@ -9,15 +11,23 @@ import { export function Tooltip({ children, content, + side, ...props }: { children: React.ReactNode; + side?: ComponentProps["side"]; content?: React.ReactNode; }) { + const hasSingleElementChild = + Children.count(children) === 1 && isValidElement(children); + const triggerChild = hasSingleElementChild ? children : {children}; + return ( - {children} - {content} + {triggerChild} + + {content} + ); } diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index b9e79878..3a85dd44 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react"; export interface SelectedSkillPayloadItem { id: string | number; name: string; + detail?: string; } export interface Translations { diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index f215c790..2d3a10e1 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -126,31 +126,66 @@ export const zhCN: Translations = { prompt: "为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。", icon: PenLineIcon, - children: [{ id: "6057", name: "生辰解语" }], + children: [ + { + id: "6057", + name: "生辰解语", + detail: + "四柱八字命理分析。\n当用户询问八字、四柱、命理、算命、Bazi、运势预测、命盘分析,\n或想了解其八字命盘、运势、大运、流年时,请使用此功能。", + }, + ], }, { suggestion: "小红书种草", prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。", icon: CompassIcon, - children: [{ id: "6099", name: "小红书笔记智造官" }], + children: [ + { + id: "6099", + name: "小红书笔记智造官", + detail: + "根据用户需求及提供资料,撰写小红书笔记内容(标题与正文)。\n生成图片卡片(封面及正文卡片),并支持发布小红书笔记。", + }, + ], }, { suggestion: "精美报告", prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。", icon: GraduationCapIcon, - children: [{ id: "6100", name: "MD 转 PDF 助手" }], + children: [ + { + id: "6100", + name: "MD 转 PDF 助手", + detail: + "将 Markdown(.md)文件转换为专业排版的 PDF 文档。\n支持将 markdown 转换为 PDF。\n支持将 .md 文件转为 .pdf。\n支持从 markdown 生成 PDF 报告。\n适用于中文技术报告、学术论文、文档编写及各类专业排版需求。", + }, + ], }, { suggestion: "excel数据处理", prompt: "对[Excel文件/数据]进行分析,生成数据洞察和可视化建议。", icon: MicroscopeIcon, - children: [{ id: "17", name: "Excel处理" }], + children: [ + { + id: "17", + name: "Excel处理", + detail: + "全面的电子表格创建、编辑与分析功能。\n支持公式运算、格式设置、数据分析和可视化呈现。\n当 Claude 需要处理各类电子表格文件(如 .xlsx、.xlsm、.csv、.tsv 等格式)时,可执行以下操作:\n(1) 创建包含公式与格式的新表格;\n(2) 读取或分析表格数据;\n(3) 在保留公式的前提下修改现有表格;\n(4) 在表格内进行数据分析与可视化处理;\n(5) 重新计算公式。", + }, + ], }, { suggestion: "营销策划", prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。", icon: ShapesIcon, - children: [{ id: "217", name: "产品营销背景" }], + children: [ + { + id: "217", + name: "产品营销背景", + detail: + "当用户需要创建或更新产品营销背景文档时使用。\n可用于沉淀目标用户、市场环境与竞争格局等关键信息。", + }, + ], }, ], suggestionsCreate: [ diff --git a/frontend/src/core/iframe-messages.ts b/frontend/src/core/iframe-messages.ts index 9008f22d..a9d6866f 100644 --- a/frontend/src/core/iframe-messages.ts +++ b/frontend/src/core/iframe-messages.ts @@ -68,6 +68,7 @@ export interface SelectedSkillMessage { export interface SelectedSkillPayloadItem { id: string | number; name: string; + detail?: string; } type UnknownRecord = Record; @@ -107,8 +108,16 @@ export function isSelectedSkillsMessage( if (!skill) return false; const id = skill.id; const name = skill.name; + const detail = skill.detail; const isValidId = typeof id === "string" || typeof id === "number"; - return isValidId && typeof name === "string" && name.trim().length > 0; + const isValidDetail = + detail === undefined || typeof detail === "string"; + return ( + isValidId && + typeof name === "string" && + name.trim().length > 0 && + isValidDetail + ); }); } diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts index bf4fcf82..90743a4e 100644 --- a/frontend/src/hooks/use-iframe-skill.ts +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -30,6 +30,26 @@ function getThreadStorageKey(threadId?: string | null): string | null { return `${STORAGE_KEYS.byThreadPrefix}${normalized}`; } +function persistSkillsToSessionStorage( + skills: SkillData[], + threadId?: string | null, +) { + const threadKey = getThreadStorageKey(threadId); + if (skills.length === 0) { + window.sessionStorage.removeItem(STORAGE_KEYS.latest); + if (threadKey) { + window.sessionStorage.removeItem(threadKey); + } + return; + } + + const payload = JSON.stringify(skills); + window.sessionStorage.setItem(STORAGE_KEYS.latest, payload); + if (threadKey) { + window.sessionStorage.setItem(threadKey, payload); + } +} + function parseStoredSkills(raw: string | null): SkillData[] { if (!raw) return []; try { @@ -215,21 +235,8 @@ export function useIframeSkill( // 4. 选择变化时同步到 sessionStorage useEffect(() => { - const threadKey = getThreadStorageKey(threadId); - if (selectedSkills.length === 0) { - // 空数组也要同步到存储,避免 UI 状态与缓存不一致 - window.sessionStorage.removeItem(STORAGE_KEYS.latest); - if (threadKey) { - window.sessionStorage.removeItem(threadKey); - } - return; - } - - const payload = JSON.stringify(selectedSkills); - window.sessionStorage.setItem(STORAGE_KEYS.latest, payload); - if (threadKey) { - window.sessionStorage.setItem(threadKey, payload); - } + // 空数组也要同步到存储,避免 UI 状态与缓存不一致 + persistSkillsToSessionStorage(selectedSkills, threadId); }, [selectedSkills, threadId]); // 发送选择预定义 skill @@ -286,6 +293,15 @@ export function useIframeSkill( }); try { + // 发起 bootstrap 时立即同步缓存;失败会在 removeFailedSkills 中回滚。 + const normalizedSkills = selectedSkills.map((item) => ({ + skill_id: String(item.id), + title: item.name, + })); + setSelectedSkill(normalizedSkills[0] ?? null); + setSelectedSkills(normalizedSkills); + persistSkillsToSessionStorage(normalizedSkills, threadId); + const result = await bootstrapRemoteSkill({ thread_id: threadId, content_ids, @@ -308,12 +324,12 @@ export function useIframeSkill( } sendSelectSkill(selectedSkills); - const normalizedSkills = selectedSkills.map((item) => ({ + const latestSkills = selectedSkills.map((item) => ({ skill_id: String(item.id), title: item.name, })); - setSelectedSkill(normalizedSkills[0] ?? null); - setSelectedSkills(normalizedSkills); + setSelectedSkill(latestSkills[0] ?? null); + setSelectedSkills(latestSkills); toast.success(t.skills.loadSuccessWithTitle(title), { description: result.message || t.skills.createdFiles(result.created_files),