未适配提示自带入队按钮,失败卡片按变体分派三颗按钮

- 未适配提示从一句话改成一张卡片:说明和「交给 code agent 适配」那颗按钮放在一起,用户不用按照提示去别处找按钮;按钮行里就不再重复一颗,同一屏同一件事只有一个入口。
- 这条提示的判据补上 form 与 failure:hasScript 初值是 false,注册表还没读回来(或读失败)时不能替项目下「还没适配」的结论;失败卡片带着宿主的现场和同一颗按钮,两张卡一起出现就是同一件事两个入口。
- 失败变体的 action 从两向扩成三向 adapt / repair / retry:buildScriptMissing 归 adapt(首次适配该发适配指令,原来发修复指令会让 agent 去读一份还不存在的适配说明),commandFailed / artifactMissing / registryMalformed 归 repair,其余仍是 retry。
- 前端用例 12 → 14:钉住「未适配时按钮行里没有适配按钮、卡片那颗只调 adapt」「读回来之前不下结论、读失败时保留按钮行的适配入口」,并把失败卡片用例扩成适配 / 修 / 重试三种变体。
This commit is contained in:
2026-10-05 17:54:08 +08:00
parent ac666daad8
commit 9d3018a49c
4 changed files with 130 additions and 35 deletions
@@ -8,8 +8,13 @@ import { expectNever } from '../../../../app/expectNever';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
/** 失败之后最合适的下一步:交给 code agent 修,还是原样重试。 */
export type XhsMinitoolFailureAction = 'adapt' | 'retry';
/**
* 失败之后最合适的下一步,也就是那颗按钮干什么:
* - `adapt`:项目还没适配(没有构建脚本),该入队的是首次适配指令;
* - `repair`:适配过了但这次没跑通(脚本非零退出、产物不在、注册表被改坏),该入队的是修复指令;
* - `retry`:不用 agent 掺和,用户自己改或原样重试。
*/
export type XhsMinitoolFailureAction = 'adapt' | 'repair' | 'retry';
export type XhsMinitoolFailureNotice = {
/** 一句话说清哪儿不对。 */
@@ -27,7 +32,7 @@ export function describeXhsMinitoolFailure(
return {
message: '导出注册表被改成了宿主认不出的形状,宿主拒绝猜测它的内容。',
detail: error.cause,
action: 'adapt',
action: 'repair',
};
case 'formInvalid':
return {
@@ -54,13 +59,13 @@ export function describeXhsMinitoolFailure(
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
detail: error.outputTail,
action: 'adapt',
action: 'repair',
};
case 'artifactMissing':
return {
message: `构建脚本跑完了,但 ${error.path} 不在。多数是把 zip 写到了别处。`,
detail: '',
action: 'adapt',
action: 'repair',
};
case 'exportUnavailable':
return {
@@ -28,6 +28,19 @@ export function ArtifactsPane({
}) {
const [downloadNotice, setDownloadNotice] = useState('');
const { form, conflict } = minitool;
/**
* 未适配:那颗按钮搬进提示卡片,说明和入口放一起,用户不用在别处找。
*
* 判据带上 `form`:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目
* 下「还没适配」的结论——那种情况只该显示读取中或失败现场。
*
* 判据带上 `failure`:失败卡片带着宿主的现场和同一颗按钮,两张卡一起出现就是同一件事两个入口。
*/
const missingScript =
!minitool.loading &&
form !== null &&
!minitool.hasScript &&
minitool.failure === null;
const downloadIcon = (): void => {
if (!projectPath || !form) return;
@@ -55,19 +68,21 @@ export function ArtifactsPane({
aria-label="导出内容区"
>
<div className="flex flex-wrap items-center gap-2">
<PaneButton
variant={minitool.hasScript ? 'ghost' : 'primary'}
onClick={minitool.adapt}
>
适配
</PaneButton>
{missingScript ? null : (
<PaneButton
variant={minitool.hasScript ? 'ghost' : 'primary'}
onClick={minitool.adapt}
>
适配
</PaneButton>
)}
<PaneButton
variant={minitool.hasScript ? 'primary' : 'ghost'}
disabled={!minitool.hasScript || minitool.running}
title={
minitool.hasScript
? `重新运行 ${XHS_MINITOOL_SCRIPT_NAME}`
: `项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME},先点「适配」让 code agent 接入`
: `项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME},用下面卡片里的按钮让 code agent 接入`
}
onClick={() => void minitool.runExport()}
>
@@ -84,10 +99,18 @@ export function ArtifactsPane({
</span>
</div>
{!minitool.hasScript ? (
<p className="rounded-lg border border-dashed border-[#e2cbb8] bg-[#fffaf5] p-2 text-xs text-[#6f5848]">
{`项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME} 脚本。点「适配」把这项工作交给 code agent:它会在项目里实验出能产出平台制品的脚本,并把这套做法固化进项目。`}
</p>
{missingScript ? (
<section
aria-label="还没有构建脚本"
className="flex flex-col items-start gap-2 rounded-xl border border-dashed border-[#e2cbb8] bg-[#fffaf5] p-3"
>
<p className="text-xs text-[#6f5848]">
{`项目里还没有 \`${XHS_MINITOOL_SCRIPT_NAME}\` 脚本——这就是「还没适配」的判据。这件事只能由 code agent 在项目里实验完成(用哪版 vite 配置、入口在哪、怎么调打包脚本都由它定):`}
</p>
<PaneButton variant="primary" onClick={minitool.adapt}>
交给 code agent 适配
</PaneButton>
</section>
) : null}
{form ? (
@@ -137,6 +160,7 @@ export function ArtifactsPane({
{minitool.failure ? (
<FailureCard
failure={minitool.failure}
onAdapt={minitool.adapt}
onRepair={minitool.repair}
onRetry={() => void minitool.runExport()}
/>
@@ -4,15 +4,18 @@ import { PaneButton } from './PaneButton';
/**
* 失败卡片:宿主给的 typed 失败原文(有一句人话 + 现场),加一颗按变体选出来的按钮。
*
* 「让 code agent 修」不是万能按钮:只有脚本/产物/注册表这类**要改项目**的失败才走它;
* 宿主自身没跑起来(`exportUnavailable`)就只给重试,别把 agent 拉进来瞎猜。
* 三颗按钮对应三种下一步:项目还没适配就入队首次适配指令,适配过但没跑通就入队修复指令,
* 宿主自身没跑起来(`exportUnavailable`)或用户能自己改的(表单、冲突)就只给重试——别把
* agent 拉进来瞎猜。
*/
export function FailureCard({
failure,
onAdapt,
onRepair,
onRetry,
}: {
failure: XhsMinitoolFailureState;
onAdapt: () => void;
onRepair: () => void;
onRetry: () => void;
}) {
@@ -31,6 +34,10 @@ export function FailureCard({
) : null}
<div className="mt-2">
{notice.action === 'adapt' ? (
<PaneButton variant="primary" onClick={onAdapt}>
交给 code agent 适配
</PaneButton>
) : notice.action === 'repair' ? (
<PaneButton variant="primary" onClick={onRepair}>
让 code agent 修
</PaneButton>
@@ -112,7 +112,7 @@ describe('交给 code agent 的指令', () => {
exitCode: 1,
outputTail: 'vite build failed',
}),
).toMatchObject({ action: 'adapt', detail: 'vite build failed' });
).toMatchObject({ action: 'repair', detail: 'vite build failed' });
expect(
describeXhsMinitoolFailure({
type: 'exportUnavailable',
@@ -169,7 +169,7 @@ describe('面板左半', () => {
});
});
it('三颗按钮各管一件事,没有脚本时导出不可点', () => {
it('没有脚本时导出不可点,适配入口在提示卡片里且只有一颗', () => {
const minitool = fakeMinitool({ hasScript: false });
renderPane(minitool);
@@ -177,7 +177,11 @@ describe('面板左半', () => {
'disabled',
true,
);
fireEvent.click(screen.getByRole('button', { name: '适配' }));
// 按钮行里的「适配」让位给卡片里那颗:同一件事不在同一屏出现两个入口。
expect(screen.queryByRole('button', { name: '适配' })).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '交给 code agent 适配' }),
);
expect(minitool.adapt).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
@@ -188,6 +192,25 @@ describe('面板左半', () => {
renderPane(withScript);
fireEvent.click(screen.getByRole('button', { name: '导出' }));
expect(withScript.runExport).toHaveBeenCalledTimes(1);
// 有脚本时那颗按钮回到按钮行,卡片不再出现。
expect(screen.getByRole('button', { name: '适配' })).not.toBeNull();
expect(
screen.queryByRole('button', { name: '交给 code agent 适配' }),
).toBeNull();
});
it('注册表还没读回来时不下「还没适配」的结论', () => {
renderPane(fakeMinitool({ form: null, loading: true, hasScript: false }));
expect(screen.queryByText(/还没有 .build:xhs-minitool. 脚本/u)).toBeNull();
expect(screen.getByText('正在读取导出注册表…')).not.toBeNull();
});
it('读失败时保留按钮行里的适配入口,不摆未适配卡片', () => {
renderPane(fakeMinitool({ form: null, loading: false, hasScript: false }));
expect(screen.queryByText(/还没有 .build:xhs-minitool. 脚本/u)).toBeNull();
expect(screen.getByRole('button', { name: '适配' })).not.toBeNull();
});
it('冲突面板逐字段二选一,按选择保存的合并结果交回上层', () => {
@@ -209,31 +232,67 @@ describe('面板左半', () => {
});
});
it('失败卡片按变体给出「让 code agent 修」或「重试」', () => {
const failure: XhsMinitoolFailureState = {
notice: {
message:
'项目里还没有 build:xhs-minitool 脚本——这就是「还没适配」的判据。',
detail: '宿主找过:package.json、game/package.json',
action: 'adapt',
},
error: {
it('失败卡片按变体给出「让 code agent 适配 / 修」或「重试」', () => {
// 还没适配:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。
const neverAdapted = fakeMinitool({
hasScript: false,
failure: failureOf({
type: 'buildScriptMissing',
script: 'build:xhs-minitool',
searched: ['package.json', 'game/package.json'],
},
};
const minitool = fakeMinitool({ hasScript: false, failure });
renderPane(minitool);
}),
});
renderPane(neverAdapted);
expect(
screen.getByText('宿主找过:package.json、game/package.json'),
).not.toBeNull();
expect(
screen.queryByRole('button', { name: '让 code agent 修' }),
).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '交给 code agent 适配' }),
);
expect(neverAdapted.adapt).toHaveBeenCalledTimes(1);
expect(neverAdapted.repair).not.toHaveBeenCalled();
// 适配过但没跑通:入队修复指令,带上这次的现场。
cleanup();
const broken = fakeMinitool({
failure: failureOf({
type: 'commandFailed',
exitCode: 1,
outputTail: 'vite build failed',
}),
});
renderPane(broken);
fireEvent.click(screen.getByRole('button', { name: '让 code agent 修' }));
expect(minitool.repair).toHaveBeenCalledTimes(1);
expect(broken.repair).toHaveBeenCalledTimes(1);
expect(broken.adapt).not.toHaveBeenCalled();
// 宿主自身没跑起来:只给重试,不把 agent 拉进来瞎猜。
cleanup();
const unavailable = fakeMinitool({
failure: failureOf({
type: 'exportUnavailable',
cause: '项目权限策略拒绝执行:command.exec',
}),
});
renderPane(unavailable);
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(unavailable.runExport).toHaveBeenCalledTimes(1);
expect(unavailable.repair).not.toHaveBeenCalled();
});
});
function failureOf(
error: XhsMinitoolFailureState['error'],
): XhsMinitoolFailureState {
return { notice: describeXhsMinitoolFailure(error), error };
}
describe('状态 hook', () => {
it('读到现场后,改字段停手会自动保存并把 baseHash 带回宿主', async () => {
const invoke = vi.fn(async (command: string) => {