feat(phase-07): finalize mention prompt behavior and docs
This commit is contained in:
parent
ebb9ca7140
commit
92a0be5274
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 个已诊断缺口。
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
}),
|
}),
|
||||||
"请总结",
|
"请总结",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue