Skip to content

Commit 2faa9f5

Browse files
黄桃黄桃
authored andcommitted
fix web clone prompt seed across type switches
1 parent 057b0f4 commit 2faa9f5

2 files changed

Lines changed: 177 additions & 7 deletions

File tree

apps/web/src/components/HomeView.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ export interface ActivePlugin {
196196
// legitimately equal the chip's default plugin id (e.g. the prototype rail's
197197
// `example-web-prototype`).
198198
explicitPick: boolean;
199+
// Temporary text inserted by a type tab rather than authored by the user.
200+
// Persist it with the chip so a remount does not convert mode UI into a
201+
// user-owned draft.
202+
promptSeedKind?: 'web-clone' | null;
199203
}
200204

201205
// `inlineBacked` distinguishes a context inserted as an inline `@mention` pill
@@ -352,6 +356,7 @@ interface HomeComposerChipDraft {
352356
pluginId: string;
353357
projectKind: ProjectKind | null;
354358
prototypeSubtypeId?: string | null;
359+
promptSeedKind?: 'web-clone' | null;
355360
}
356361
// `EntryShell` keeps `HomeView` permanently mounted and toggles it with CSS
357362
// visibility instead of unmounting it on every Home/Community/... view
@@ -426,6 +431,7 @@ function readHomeComposerChipDraft(): HomeComposerChipDraft | null {
426431
pluginId: parsed.pluginId,
427432
projectKind: typeof parsed.projectKind === 'string' ? (parsed.projectKind as ProjectKind) : null,
428433
prototypeSubtypeId: parsedPrototypeSubtype?.slug ?? legacyPrototypeSubtype?.slug ?? null,
434+
promptSeedKind: parsed.promptSeedKind === 'web-clone' ? 'web-clone' : null,
429435
};
430436
} catch {
431437
return null;
@@ -459,6 +465,23 @@ export function seedHomeComposerPrompt(prompt: string): void {
459465
}
460466
}
461467

468+
function shouldClearWebClonePromptSeedOnTypeSwitch({
469+
activeChipId,
470+
nextChipId,
471+
promptEditedByUser,
472+
promptSeedKind,
473+
}: {
474+
activeChipId: string | null;
475+
nextChipId: string;
476+
promptEditedByUser: boolean;
477+
promptSeedKind: ActivePlugin['promptSeedKind'];
478+
}): boolean {
479+
return activeChipId === 'web-clone'
480+
&& nextChipId !== 'web-clone'
481+
&& !promptEditedByUser
482+
&& promptSeedKind === 'web-clone';
483+
}
484+
462485
export function HomeView({
463486
isActive = true,
464487
projects,
@@ -644,6 +667,15 @@ export function HomeView({
644667
};
645668
}
646669
const restoredDraft = restoredDraftRef.current;
670+
// Upgrade cleanup for drafts produced by the old behavior: after a user
671+
// switched away from Website clone, the chip draft already named the new
672+
// type while the untouched Website-clone scaffold remained in the prompt.
673+
// That exact impossible pairing is safe to discard on the first fixed load.
674+
const restoredPrompt = pendingChipRestore?.chipId
675+
&& pendingChipRestore.chipId !== 'web-clone'
676+
&& restoredDraft.prompt === t('homeHero.chip.webClonePromptSeed')
677+
? ''
678+
: restoredDraft.prompt;
647679
const [designSystemId, setDesignSystemId] = useState<string | null>(() =>
648680
restoredDraft.designSystemId ??
649681
homeDefaultDesignSystemId(designSystems, defaultDesignSystemId),
@@ -679,11 +711,20 @@ export function HomeView({
679711
}, []);
680712
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
681713
const [mcpLoading, setMcpLoading] = useState(true);
682-
const [prompt, setPrompt] = useState(() => restoredDraft.prompt);
714+
const [prompt, setPrompt] = useState(() => restoredPrompt);
683715
// Treat a restored non-empty prompt as user-edited so the plugin/skill
684-
// replacement guard still asks before clobbering it.
716+
// replacement guard still asks before clobbering it. The Website-clone
717+
// scaffold is mode UI, not a user draft; the exact-text fallback migrates
718+
// drafts saved before promptSeedKind was persisted.
685719
const [promptEditedByUser, setPromptEditedByUser] = useState(
686-
() => restoredDraft.prompt.trim().length > 0,
720+
() => restoredPrompt.trim().length > 0
721+
&& !(
722+
pendingChipRestore?.chipId === 'web-clone'
723+
&& (
724+
pendingChipRestore.promptSeedKind === 'web-clone'
725+
|| restoredPrompt === t('homeHero.chip.webClonePromptSeed')
726+
)
727+
),
687728
);
688729
// Persist the composer draft on every change so it survives the unmount that
689730
// a tab switch triggers (see the module note above). Empty values clear the
@@ -716,6 +757,7 @@ export function HomeView({
716757
...(active.prototypeSubtypeId
717758
? { prototypeSubtypeId: active.prototypeSubtypeId }
718759
: {}),
760+
...(active.promptSeedKind ? { promptSeedKind: active.promptSeedKind } : {}),
719761
}
720762
: null,
721763
);
@@ -1391,6 +1433,7 @@ export function HomeView({
13911433
// or Community card / detail modal) rather than a type chip's default
13921434
// plugin. Stored on `active.explicitPick`; gates the chip's clear button.
13931435
explicitPick?: boolean;
1436+
promptSeedKind?: ActivePlugin['promptSeedKind'];
13941437
},
13951438
// Resolves true when the bound plugin left the composer submittable
13961439
// (inputs valid, apply not failed/superseded) — callers use this to
@@ -1460,6 +1503,7 @@ export function HomeView({
14601503
preserveInputFields: options?.preserveInputFields === true,
14611504
suppressPromptSync: suppressPromptUpdate,
14621505
explicitPick: options?.explicitPick === true,
1506+
promptSeedKind: options?.promptSeedKind ?? null,
14631507
});
14641508
setFallbackProjectKind(null);
14651509
setFallbackProjectMetadata(null);
@@ -1611,6 +1655,7 @@ export function HomeView({
16111655
replaceWithoutConfirmation?: boolean;
16121656
suppressPromptUpdate?: boolean;
16131657
deferApply?: boolean;
1658+
promptSeedKind?: ActivePlugin['promptSeedKind'];
16141659
},
16151660
) {
16161661
const inputFields = options?.inputFields ?? record.manifest?.od?.inputs ?? [];
@@ -1833,6 +1878,13 @@ export function HomeView({
18331878
? findChip(restore.chipId)
18341879
: null;
18351880
const restoredAction = restoredActionChip?.action;
1881+
const restoredPromptSeedKind = restore.promptSeedKind
1882+
?? (
1883+
restore.chipId === 'web-clone'
1884+
&& prompt === t('homeHero.chip.webClonePromptSeed')
1885+
? 'web-clone'
1886+
: null
1887+
);
18361888
requestActivePlugin(record, undefined, {
18371889
chipId: restore.chipId ?? undefined,
18381890
prototypeSubtypeId: restoredSubtype?.slug ?? null,
@@ -1848,6 +1900,7 @@ export function HomeView({
18481900
replaceWithoutConfirmation: true,
18491901
suppressPromptUpdate: true,
18501902
deferApply: true,
1903+
promptSeedKind: restoredPromptSeedKind,
18511904
});
18521905
// eslint-disable-next-line react-hooks/exhaustive-deps
18531906
}, [pendingChipRestore, pluginsLoading, plugins, active, pendingPluginUseHandoff]);
@@ -2030,6 +2083,9 @@ export function HomeView({
20302083
function handlePromptChange(nextPrompt: string) {
20312084
setPrompt(nextPrompt);
20322085
setPromptEditedByUser(true);
2086+
if (active?.promptSeedKind) {
2087+
setActive({ ...active, promptSeedKind: null });
2088+
}
20332089
if (!active?.queryTemplate) return;
20342090
const extracted = extractPluginInputsFromPrompt(
20352091
active.queryTemplate,
@@ -2442,6 +2498,20 @@ export function HomeView({
24422498
);
24432499
return;
24442500
}
2501+
// Website clone is the only type tab that inserts a scaffold directly
2502+
// into an empty composer. Treat that exact untouched scaffold as mode
2503+
// UI, not as a user draft, so it cannot leak into the next type tab.
2504+
// Any edit (including typing the same text manually) transfers
2505+
// ownership to the user and keeps the normal draft-preservation rule.
2506+
if (shouldClearWebClonePromptSeedOnTypeSwitch({
2507+
activeChipId: active?.chipId ?? null,
2508+
nextChipId: activeChipId,
2509+
promptEditedByUser,
2510+
promptSeedKind: active?.promptSeedKind ?? null,
2511+
})) {
2512+
setPrompt('');
2513+
setPromptEditedByUser(false);
2514+
}
24452515
const mediaSurface = homeMediaSurfaceForChipId(chip.id);
24462516
if (mediaSurface) {
24472517
const composer = buildHomeMediaComposer(
@@ -2504,6 +2574,7 @@ export function HomeView({
25042574
...pluginOptions,
25052575
suppressPromptUpdate: promptSeed === null,
25062576
deferApply: true,
2577+
promptSeedKind: promptSeed === null ? null : 'web-clone',
25072578
});
25082579
} else {
25092580
requestActivePlugin(record, undefined, pluginOptions);

apps/web/tests/components/HomeView.web-clone-tracking.test.tsx

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
// project_kind=web_clone on project_create_result.
99

1010
import { afterEach, describe, expect, it, vi } from 'vitest';
11-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
1212

1313
import { HomeView } from '../../src/components/HomeView';
1414
import { I18nProvider } from '../../src/i18n';
1515
import { writeHomeGuideStage } from '../../src/components/home-hero/firstRunGuide';
16+
import { homeHeroPromptText, setHomeHeroPrompt } from '../helpers/home-hero-lexical';
1617

1718
const analyticsMocks = vi.hoisted(() => ({ track: vi.fn() }));
1819

@@ -67,11 +68,31 @@ const WEB_CLONE_BASE = {
6768
},
6869
};
6970

71+
const WEB_PROTOTYPE_BASE = {
72+
...WEB_CLONE_BASE,
73+
id: 'example-web-prototype',
74+
title: 'Web Prototype',
75+
source: '/tmp/web-prototype',
76+
fsPath: '/tmp/web-prototype',
77+
manifest: {
78+
...WEB_CLONE_BASE.manifest,
79+
name: 'example-web-prototype',
80+
title: 'Web Prototype',
81+
description: 'Create an interactive web prototype.',
82+
tags: ['prototype'],
83+
od: {
84+
kind: 'scenario',
85+
taskKind: 'new-generation',
86+
useCase: { query: 'Create an interactive web prototype.' },
87+
},
88+
},
89+
};
90+
7091
function stubPlugins() {
7192
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
7293
const href = typeof url === 'string' ? url : url.toString();
7394
if (href === '/api/plugins') {
74-
return new Response(JSON.stringify({ plugins: [WEB_CLONE_BASE] }), {
95+
return new Response(JSON.stringify({ plugins: [WEB_CLONE_BASE, WEB_PROTOTYPE_BASE] }), {
7596
status: 200,
7697
headers: { 'content-type': 'application/json' },
7798
});
@@ -83,9 +104,9 @@ function stubPlugins() {
83104
}));
84105
}
85106

86-
function renderHome() {
107+
function renderHome(locale: 'en' | 'zh-CN' = 'en') {
87108
return render(
88-
<I18nProvider initial="en">
109+
<I18nProvider initial={locale}>
89110
<HomeView
90111
projects={[]}
91112
onSubmit={() => undefined}
@@ -113,6 +134,84 @@ async function pickHomeTemplate(id: string) {
113134
}
114135

115136
describe('web-clone example-card tracking', () => {
137+
it('clears the untouched Website-clone URL scaffold when another type tab is selected', async () => {
138+
writeHomeGuideStage('done');
139+
stubPlugins();
140+
renderHome('zh-CN');
141+
142+
await pickHomeTemplate('web-clone');
143+
await waitFor(() => {
144+
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
145+
});
146+
147+
await pickHomeTemplate('prototype');
148+
await waitFor(() => {
149+
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
150+
});
151+
});
152+
153+
it('preserves a Website-clone URL after the user edits the scaffold', async () => {
154+
writeHomeGuideStage('done');
155+
stubPlugins();
156+
renderHome('zh-CN');
157+
158+
await pickHomeTemplate('web-clone');
159+
await waitFor(() => {
160+
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
161+
});
162+
163+
setHomeHeroPrompt('想要复刻的网站链接:https://example.com');
164+
await act(async () => {
165+
await Promise.resolve();
166+
});
167+
await pickHomeTemplate('prototype');
168+
169+
await waitFor(() => {
170+
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:https://example.com');
171+
});
172+
});
173+
174+
it('keeps the Website-clone scaffold system-owned across an unmount and remount', async () => {
175+
writeHomeGuideStage('done');
176+
stubPlugins();
177+
const firstMount = renderHome('zh-CN');
178+
179+
await pickHomeTemplate('web-clone');
180+
await waitFor(() => {
181+
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
182+
expect(JSON.parse(window.localStorage.getItem('open-design:home-composer:chip')!))
183+
.toMatchObject({ chipId: 'web-clone', promptSeedKind: 'web-clone' });
184+
});
185+
186+
firstMount.unmount();
187+
renderHome('zh-CN');
188+
await waitFor(() => {
189+
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
190+
});
191+
await pickHomeTemplate('prototype');
192+
193+
await waitFor(() => {
194+
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
195+
});
196+
});
197+
198+
it('cleans up a Website-clone scaffold leaked into another persisted type by the old behavior', async () => {
199+
writeHomeGuideStage('done');
200+
stubPlugins();
201+
window.localStorage.setItem('open-design:home-composer:prompt', '想要复刻的网站链接:');
202+
window.localStorage.setItem('open-design:home-composer:chip', JSON.stringify({
203+
chipId: 'prototype',
204+
pluginId: 'example-web-prototype',
205+
projectKind: 'prototype',
206+
}));
207+
208+
renderHome('zh-CN');
209+
210+
await waitFor(() => {
211+
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
212+
});
213+
});
214+
116215
it('renders the Website-clone examples as text prompt cards (no plugin preview / no remix)', async () => {
117216
writeHomeGuideStage('done');
118217
stubPlugins();

0 commit comments

Comments
 (0)