feat(phase-07): finalize mention prompt behavior and docs

This commit is contained in:
肖应宇 2026-04-17 14:13:54 +08:00
parent ebb9ca7140
commit 92a0be5274
9 changed files with 122 additions and 51 deletions

View File

@ -28,10 +28,10 @@ must_haves:
artifacts: artifacts:
- path: "frontend/src/components/workspace/artifacts/artifact-file-list.tsx" - path: "frontend/src/components/workspace/artifacts/artifact-file-list.tsx"
provides: "ContextMenu 引用动作改为显式点击触发" provides: "ContextMenu 引用动作改为显式点击触发"
contains: "ContextMenuItem reference handler" contains: "onClick={() => {"
- path: "frontend/src/core/threads/hooks.ts" - path: "frontend/src/core/threads/hooks.ts"
provides: "skill_id 拼接入 submitText" provides: "skill_id 拼接入 submitText"
contains: "selected skills mapping" contains: "skill.skill_id"
- path: "frontend/src/core/messages/utils.ts" - path: "frontend/src/core/messages/utils.ts"
provides: "XClaw 前缀剥离" provides: "XClaw 前缀剥离"
contains: "stripPriorityHintSuffix" contains: "stripPriorityHintSuffix"
@ -56,6 +56,10 @@ Output: 修复提交链路与右键引用交互,并补齐回归测试。
- 右键打开菜单时不会自动触发引用。 - 右键打开菜单时不会自动触发引用。
- 菜单项点击后才触发引用并回填输入区。 - 菜单项点击后才触发引用并回填输入区。
</acceptance_criteria> </acceptance_criteria>
<verify>
<automated>rg -n "ContextMenuItem|onSelect|onClick|dispatchMentionReference" frontend/src/components/workspace/artifacts/artifact-file-list.tsx frontend/src/components/workspace/messages/message-list-item.tsx</automated>
</verify>
<done>ContextMenu 引用行为仅由显式用户点击触发,右键打开菜单不再自动引用。</done>
</task> </task>
<task> <task>
@ -69,6 +73,10 @@ Output: 修复提交链路与右键引用交互,并补齐回归测试。
- 消息区仍不显示该后缀。 - 消息区仍不显示该后缀。
- 单测全部通过。 - 单测全部通过。
</acceptance_criteria> </acceptance_criteria>
<verify>
<automated>rg -n "XClaw优先使用|stripPriorityHintSuffix|composeSubmitText" frontend/src/core/threads/priority-hint.ts frontend/src/core/messages/utils.ts frontend/src/core/threads/hooks.ts</automated>
</verify>
<done>前缀与剥离规则统一为 XClaw 版本,提交态与展示态语义保持一致。</done>
</task> </task>
<task> <task>
@ -81,12 +89,16 @@ Output: 修复提交链路与右键引用交互,并补齐回归测试。
- 拼接中 Skill 部分使用 id 列表。 - 拼接中 Skill 部分使用 id 列表。
- 发送按钮与回车路径行为一致。 - 发送按钮与回车路径行为一致。
</acceptance_criteria> </acceptance_criteria>
<verify>
<automated>rg -n "selectedSkills|skill_id|composeSubmitText" frontend/src/core/threads/hooks.ts</automated>
<automated>cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"</automated>
</verify>
<done>提交提示中的 Skill 标识稳定使用 skill_id且主要发送入口回归通过。</done>
</task> </task>
</tasks> </tasks>
<verification> <verification>
- `cd frontend && node --test src/core/threads/hooks.test.ts`
- `cd frontend && pnpm -s typecheck` - `cd frontend && pnpm -s typecheck`
- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"` - `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"`
</verification> </verification>

View File

@ -0,0 +1,59 @@
---
phase: 07-phase-06-mention-upload
plan: 02
subsystem: gap-closure
tags: [context-menu, priority-hint, skill-id, references, e2e]
requires:
- phase: 07-phase-06-mention-upload
provides: 07-01-SUMMARY.md
provides:
- 修复右键打开 ContextMenu 时误触发“引用”的问题
- 优先提示前缀统一为“XClaw优先使用”并与展示层剥离规则对齐
- 提交态 Skill 拼接使用 skill_id避免使用展示名 title
affects: [frontend-chat-input, message-render, thread-submit-payload]
tech-stack:
added: []
patterns:
- explicit-click-only context-menu reference action
- submit/display separation with stable id-based hint composition
key-files:
created:
- .planning/phases/07-phase-06-mention-upload/07-02-SUMMARY.md
modified:
- frontend/src/components/workspace/artifacts/artifact-file-list.tsx
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/src/core/threads/priority-hint.ts
- frontend/src/core/messages/utils.ts
- frontend/src/core/threads/hooks.ts
- frontend/src/core/threads/hooks.test.ts
- frontend/tests/e2e/input-and-compose.spec.ts
key-decisions:
- "ContextMenu 引用动作仅绑定显式点击,移除 onSelect 触发路径。"
- "优先提示统一改为 XClaw 前缀,并同步更新消息展示剥离规则。"
- "Skill 拼接数据源统一使用 selectedSkills.skill_id。"
requirements-completed: [P7-01, P7-02, P7-03, P7-04]
duration: 35 min
completed: 2026-04-17
---
# Phase 07 Plan 02 Summary
完成了 Phase 07 的 3 个 UAT gap closure引用误触发、提示前缀唯一化、Skill 提示标识稳定化。
## Implemented
- 将 artifact 列表与消息附件中的 `ContextMenuItem` 引用动作从 `onSelect` 改为 `onClick`,避免仅右键打开菜单就自动引用。
- `priority-hint` 规则升级为 `XClaw优先使用...`并保持“附件在前、Skill 在后、大小写不敏感去重”。
- `stripPriorityHintSuffix` 同步匹配新前缀,确保消息区继续只展示用户原文。
- `hooks.ts` 在两条发送链路中均改为使用 `selectedSkills.skill_id` 参与提交态拼接。
- 单测与 E2E 断言同步更新到新前缀。
## Verification
- `node --test frontend/src/core/threads/hooks.test.ts`7 passed
- `cd frontend && pnpm -s typecheck`passed
- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"`1 passed
## Notes
- 本计划为 `gap_closure: true`,直接对应 `07-UAT.md` 中 3 个已诊断缺口。

View File

@ -152,7 +152,7 @@ export function ArtifactFileList({
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="min-w-[120px]"> <ContextMenuContent className="min-w-[120px]">
<ContextMenuItem <ContextMenuItem
onSelect={() => { onClick={() => {
dispatchMentionReference({ dispatchMentionReference({
threadId, threadId,
filename: getFileName(file), filename: getFileName(file),

View File

@ -424,22 +424,22 @@ function RichFileCard({
/> />
</a> </a>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="min-w-[120px]"> <ContextMenuContent className="min-w-[120px]">
<ContextMenuItem <ContextMenuItem
disabled={!canReference} disabled={!canReference}
onSelect={() => { onClick={() => {
if (!file.path) return; if (!file.path) return;
dispatchMentionReference({ dispatchMentionReference({
threadId, threadId,
filename: file.filename, filename: file.filename,
path: file.path, path: file.path,
ref_source: refSource, ref_source: refSource,
}); });
}} }}
> >
{t.common.reference} {t.common.reference}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
} }

View File

@ -354,13 +354,13 @@ export function stripUploadedFilesTag(content: string): string {
/** /**
* Strip the appended priority-hint suffix from a message content. * Strip the appended priority-hint suffix from a message content.
* Suffix format: * Suffix format:
* - 使... * - XClaw优先使...
* - 使Skill... * - XClaw优先使Skill...
* - 使...Skill... * - XClaw优先使...Skill...
*/ */
export function stripPriorityHintSuffix(content: string): string { export function stripPriorityHintSuffix(content: string): string {
return content return content
.replace(/\n?使[^]+(?:[^]+)?\s*$/u, "") .replace(/\n?XClaw使[^]+(?:[^]+)?\s*$/u, "")
.trim(); .trim();
} }

View File

@ -74,34 +74,34 @@ void test("buildFilesForSubmit keeps artifact mention path without re-upload", (
void test("buildPriorityHintText keeps attachments first and skills second", () => { void test("buildPriorityHintText keeps attachments first and skills second", () => {
const result = buildPriorityHintText({ const result = buildPriorityHintText({
attachmentNames: ["spec.md"], attachmentNames: ["spec.md"],
skillTitles: ["文档生成"], skillIds: ["skill.docs.generate"],
}); });
assert.equal(result, "优先使用【spec.md】和【文档生成】"); assert.equal(result, "XClaw优先使用【spec.md】和【skill.docs.generate】");
}); });
void test("buildPriorityHintText outputs single category when the other is empty", () => { void test("buildPriorityHintText outputs single category when the other is empty", () => {
assert.equal( assert.equal(
buildPriorityHintText({ buildPriorityHintText({
attachmentNames: ["spec.md"], attachmentNames: ["spec.md"],
skillTitles: [], skillIds: [],
}), }),
"优先使用【spec.md】", "XClaw优先使用【spec.md】",
); );
assert.equal( assert.equal(
buildPriorityHintText({ buildPriorityHintText({
attachmentNames: [], attachmentNames: [],
skillTitles: ["文档生成"], skillIds: ["skill.docs.generate"],
}), }),
"优先使用【文档生成】", "XClaw优先使用【skill.docs.generate】",
); );
}); });
void test("buildPriorityHintText deduplicates case-insensitively", () => { void test("buildPriorityHintText deduplicates case-insensitively", () => {
const result = buildPriorityHintText({ const result = buildPriorityHintText({
attachmentNames: ["Spec.md", "spec.md", " SPEC.md "], attachmentNames: ["Spec.md", "spec.md", " SPEC.md "],
skillTitles: ["Excel", "excel"], skillIds: ["skill.excel", "SKILL.EXCEL"],
}); });
assert.equal(result, "优先使用【Spec.md】和【Excel】"); assert.equal(result, "XClaw优先使用【Spec.md】和【skill.excel】");
}); });
void test("composeSubmitText appends hint only when needed", () => { void test("composeSubmitText appends hint only when needed", () => {
@ -109,16 +109,16 @@ void test("composeSubmitText appends hint only when needed", () => {
composeSubmitText({ composeSubmitText({
baseText: "请总结", baseText: "请总结",
attachmentNames: ["spec.md"], attachmentNames: ["spec.md"],
skillTitles: [], skillIds: [],
}), }),
"请总结\n优先使用【spec.md】", "请总结\nXClaw优先使用【spec.md】",
); );
assert.equal( assert.equal(
composeSubmitText({ composeSubmitText({
baseText: "请总结", baseText: "请总结",
attachmentNames: [], attachmentNames: [],
skillTitles: [], skillIds: [],
}), }),
"请总结", "请总结",
); );

View File

@ -408,8 +408,8 @@ export function useThreadStream({
const referenceNames = (message.references ?? []).map( const referenceNames = (message.references ?? []).map(
(reference) => reference.filename, (reference) => reference.filename,
); );
const selectedSkillTitles = (message.selectedSkills ?? []).map( const selectedSkillIds = (message.selectedSkills ?? []).map(
(skill) => skill.title, (skill) => skill.skill_id,
); );
const resolvedThreadId = const resolvedThreadId =
normalizeThreadId(threadId) ?? normalizeThreadId(threadId) ??
@ -578,7 +578,7 @@ export function useThreadStream({
const submitText = composeSubmitText({ const submitText = composeSubmitText({
baseText: text, baseText: text,
attachmentNames: [...uploadedNames, ...referenceNames], attachmentNames: [...uploadedNames, ...referenceNames],
skillTitles: selectedSkillTitles, skillIds: selectedSkillIds,
}); });
await thread.submit( await thread.submit(
@ -692,8 +692,8 @@ export function useSubmitThread({
const referenceNames = (message.references ?? []).map( const referenceNames = (message.references ?? []).map(
(reference) => reference.filename, (reference) => reference.filename,
); );
const selectedSkillTitles = (message.selectedSkills ?? []).map( const selectedSkillIds = (message.selectedSkills ?? []).map(
(skill) => skill.title, (skill) => skill.skill_id,
); );
const hasFiles = !!(message.files && message.files.length > 0); const hasFiles = !!(message.files && message.files.length > 0);
@ -766,7 +766,7 @@ export function useSubmitThread({
const submitText = composeSubmitText({ const submitText = composeSubmitText({
baseText: text, baseText: text,
attachmentNames: [...uploadedNames, ...referenceNames], attachmentNames: [...uploadedNames, ...referenceNames],
skillTitles: selectedSkillTitles, skillIds: selectedSkillIds,
}); });
await thread.submit( await thread.submit(

View File

@ -14,13 +14,13 @@ function uniqueNormalizedValues(values: Array<string | undefined>): string[] {
export function buildPriorityHintText({ export function buildPriorityHintText({
attachmentNames, attachmentNames,
skillTitles, skillIds,
}: { }: {
attachmentNames: string[]; attachmentNames: string[];
skillTitles: string[]; skillIds: string[];
}): string { }): string {
const attachments = uniqueNormalizedValues(attachmentNames); const attachments = uniqueNormalizedValues(attachmentNames);
const skills = uniqueNormalizedValues(skillTitles); const skills = uniqueNormalizedValues(skillIds);
if (attachments.length === 0 && skills.length === 0) { if (attachments.length === 0 && skills.length === 0) {
return ""; return "";
} }
@ -30,25 +30,25 @@ export function buildPriorityHintText({
const skillPart = skills.length > 0 ? `${skills.join("、")}` : ""; const skillPart = skills.length > 0 ? `${skills.join("、")}` : "";
if (attachmentPart && skillPart) { if (attachmentPart && skillPart) {
return `优先使用${attachmentPart}${skillPart}`; return `XClaw优先使用${attachmentPart}${skillPart}`;
} }
return `优先使用${attachmentPart || skillPart}`; return `XClaw优先使用${attachmentPart || skillPart}`;
} }
export function composeSubmitText({ export function composeSubmitText({
baseText, baseText,
attachmentNames, attachmentNames,
skillTitles, skillIds,
}: { }: {
baseText: string; baseText: string;
attachmentNames: string[]; attachmentNames: string[];
skillTitles: string[]; skillIds: string[];
}): string { }): string {
const trimmedBase = baseText.trim(); const trimmedBase = baseText.trim();
if (!trimmedBase) return trimmedBase; if (!trimmedBase) return trimmedBase;
const priorityHint = buildPriorityHintText({ const priorityHint = buildPriorityHintText({
attachmentNames, attachmentNames,
skillTitles, skillIds,
}); });
if (!priorityHint) return trimmedBase; if (!priorityHint) return trimmedBase;
return `${trimmedBase}\n${priorityHint}`; return `${trimmedBase}\n${priorityHint}`;

View File

@ -285,10 +285,10 @@ test.describe("聊天工作台 / 输入区与发送", () => {
const requestBody = request.postData() ?? ""; const requestBody = request.postData() ?? "";
expect(requestBody).toContain(userInput); expect(requestBody).toContain(userInput);
expect(requestBody).toContain("优先使用【"); expect(requestBody).toContain("XClaw优先使用【");
expect(requestBody).toContain(referenceName); expect(requestBody).toContain(referenceName);
await expect( await expect(
page.locator(".is-user").filter({ hasText: "优先使用【" }), page.locator(".is-user").filter({ hasText: "XClaw优先使用【" }),
).toHaveCount(0); ).toHaveCount(0);
}); });