Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions apps/web/src/components/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ export interface ActivePlugin {
// legitimately equal the chip's default plugin id (e.g. the prototype rail's
// `example-web-prototype`).
explicitPick: boolean;
// Temporary text inserted by a type tab rather than authored by the user.
// Persist it with the chip so a remount does not convert mode UI into a
// user-owned draft.
promptSeedKind?: 'web-clone' | null;
}

// `inlineBacked` distinguishes a context inserted as an inline `@mention` pill
Expand Down Expand Up @@ -352,6 +356,7 @@ interface HomeComposerChipDraft {
pluginId: string;
projectKind: ProjectKind | null;
prototypeSubtypeId?: string | null;
promptSeedKind?: 'web-clone' | null;
}
// `EntryShell` keeps `HomeView` permanently mounted and toggles it with CSS
// visibility instead of unmounting it on every Home/Community/... view
Expand Down Expand Up @@ -426,6 +431,7 @@ function readHomeComposerChipDraft(): HomeComposerChipDraft | null {
pluginId: parsed.pluginId,
projectKind: typeof parsed.projectKind === 'string' ? (parsed.projectKind as ProjectKind) : null,
prototypeSubtypeId: parsedPrototypeSubtype?.slug ?? legacyPrototypeSubtype?.slug ?? null,
promptSeedKind: parsed.promptSeedKind === 'web-clone' ? 'web-clone' : null,
};
} catch {
return null;
Expand Down Expand Up @@ -459,6 +465,23 @@ export function seedHomeComposerPrompt(prompt: string): void {
}
}

function shouldClearWebClonePromptSeedOnTypeSwitch({
activeChipId,
nextChipId,
promptEditedByUser,
promptSeedKind,
}: {
activeChipId: string | null;
nextChipId: string;
promptEditedByUser: boolean;
promptSeedKind: ActivePlugin['promptSeedKind'];
}): boolean {
return activeChipId === 'web-clone'
&& nextChipId !== 'web-clone'
&& !promptEditedByUser
&& promptSeedKind === 'web-clone';
}

export function HomeView({
isActive = true,
projects,
Expand Down Expand Up @@ -644,6 +667,15 @@ export function HomeView({
};
}
const restoredDraft = restoredDraftRef.current;
// Upgrade cleanup for drafts produced by the old behavior: after a user
// switched away from Website clone, the chip draft already named the new
// type while the untouched Website-clone scaffold remained in the prompt.
// That exact impossible pairing is safe to discard on the first fixed load.
const restoredPrompt = pendingChipRestore?.chipId
&& pendingChipRestore.chipId !== 'web-clone'
&& restoredDraft.prompt === t('homeHero.chip.webClonePromptSeed')
? ''
: restoredDraft.prompt;
const [designSystemId, setDesignSystemId] = useState<string | null>(() =>
restoredDraft.designSystemId ??
homeDefaultDesignSystemId(designSystems, defaultDesignSystemId),
Expand Down Expand Up @@ -679,11 +711,20 @@ export function HomeView({
}, []);
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
const [mcpLoading, setMcpLoading] = useState(true);
const [prompt, setPrompt] = useState(() => restoredDraft.prompt);
const [prompt, setPrompt] = useState(() => restoredPrompt);
// Treat a restored non-empty prompt as user-edited so the plugin/skill
// replacement guard still asks before clobbering it.
// replacement guard still asks before clobbering it. The Website-clone
// scaffold is mode UI, not a user draft; the exact-text fallback migrates
// drafts saved before promptSeedKind was persisted.
const [promptEditedByUser, setPromptEditedByUser] = useState(
() => restoredDraft.prompt.trim().length > 0,
() => restoredPrompt.trim().length > 0
&& !(
pendingChipRestore?.chipId === 'web-clone'
&& (
pendingChipRestore.promptSeedKind === 'web-clone'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revoke a stale scaffold marker before treating every restored prompt as system-owned. The existing HOME_COMPOSER_SEED_EVENT path at onSeed replaces the prompt and marks it edited, but it does not clear active.promptSeedKind; the persistence effect therefore saves { chipId: 'web-clone', promptSeedKind: 'web-clone' } beside the handed-off prompt. After a real unmount/remount, this branch marks that arbitrary non-empty prompt unedited solely because the marker exists, and switching away from Website clone then clears it. This loses the prompt passed by seedHomeComposerPrompt, a documented cross-surface flow. Clear promptSeedKind whenever a non-scaffold prompt replaces the seed (including the live seed event), or centralize prompt replacement so the marker can only remain while the prompt is exactly the scaffold. Add a regression that selects Website clone, dispatches a different live seed, unmounts/remounts, switches to Prototype, and verifies the handed-off prompt remains.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

|| restoredPrompt === t('homeHero.chip.webClonePromptSeed')
)
),
);
// Persist the composer draft on every change so it survives the unmount that
// a tab switch triggers (see the module note above). Empty values clear the
Expand Down Expand Up @@ -716,6 +757,7 @@ export function HomeView({
...(active.prototypeSubtypeId
? { prototypeSubtypeId: active.prototypeSubtypeId }
: {}),
...(active.promptSeedKind ? { promptSeedKind: active.promptSeedKind } : {}),
}
: null,
);
Expand Down Expand Up @@ -1391,6 +1433,7 @@ export function HomeView({
// or Community card / detail modal) rather than a type chip's default
// plugin. Stored on `active.explicitPick`; gates the chip's clear button.
explicitPick?: boolean;
promptSeedKind?: ActivePlugin['promptSeedKind'];
},
// Resolves true when the bound plugin left the composer submittable
// (inputs valid, apply not failed/superseded) — callers use this to
Expand Down Expand Up @@ -1460,6 +1503,7 @@ export function HomeView({
preserveInputFields: options?.preserveInputFields === true,
suppressPromptSync: suppressPromptUpdate,
explicitPick: options?.explicitPick === true,
promptSeedKind: options?.promptSeedKind ?? null,
});
setFallbackProjectKind(null);
setFallbackProjectMetadata(null);
Expand Down Expand Up @@ -1611,6 +1655,7 @@ export function HomeView({
replaceWithoutConfirmation?: boolean;
suppressPromptUpdate?: boolean;
deferApply?: boolean;
promptSeedKind?: ActivePlugin['promptSeedKind'];
},
) {
const inputFields = options?.inputFields ?? record.manifest?.od?.inputs ?? [];
Expand Down Expand Up @@ -1833,6 +1878,13 @@ export function HomeView({
? findChip(restore.chipId)
: null;
const restoredAction = restoredActionChip?.action;
const restoredPromptSeedKind = restore.promptSeedKind
?? (
restore.chipId === 'web-clone'
&& prompt === t('homeHero.chip.webClonePromptSeed')
? 'web-clone'
: null
);
requestActivePlugin(record, undefined, {
chipId: restore.chipId ?? undefined,
prototypeSubtypeId: restoredSubtype?.slug ?? null,
Expand All @@ -1848,6 +1900,7 @@ export function HomeView({
replaceWithoutConfirmation: true,
suppressPromptUpdate: true,
deferApply: true,
promptSeedKind: restoredPromptSeedKind,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingChipRestore, pluginsLoading, plugins, active, pendingPluginUseHandoff]);
Expand Down Expand Up @@ -2030,6 +2083,9 @@ export function HomeView({
function handlePromptChange(nextPrompt: string) {
setPrompt(nextPrompt);
setPromptEditedByUser(true);
if (active?.promptSeedKind) {
setActive({ ...active, promptSeedKind: null });
Comment on lines +2114 to +2115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear seed ownership for programmatic prompt replacements too. These changed lines revoke promptSeedKind only when handlePromptChange processes an editor edit, but the existing HOME_COMPOSER_SEED_EVENT handler replaces the prompt with setPrompt(prompt) and marks it user-edited without clearing active.promptSeedKind. The persistence effect then stores the handed-off prompt beside promptSeedKind: 'web-clone'; after unmount/remount, the new restore logic treats that arbitrary prompt as system-owned, and switching to Prototype deletes it. Update the live seed handler (and preferably centralize all non-scaffold prompt replacement) to clear promptSeedKind, then add a regression that selects Website clone, calls seedHomeComposerPrompt with different text while mounted, unmounts/remounts, switches to Prototype, and verifies that text remains.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}
if (!active?.queryTemplate) return;
const extracted = extractPluginInputsFromPrompt(
active.queryTemplate,
Expand Down Expand Up @@ -2442,6 +2498,20 @@ export function HomeView({
);
return;
}
// Website clone is the only type tab that inserts a scaffold directly
// into an empty composer. Treat that exact untouched scaffold as mode
// UI, not as a user draft, so it cannot leak into the next type tab.
// Any edit (including typing the same text manually) transfers
// ownership to the user and keeps the normal draft-preservation rule.
if (shouldClearWebClonePromptSeedOnTypeSwitch({
activeChipId: active?.chipId ?? null,
nextChipId: activeChipId,
promptEditedByUser,
promptSeedKind: active?.promptSeedKind ?? null,
})) {
setPrompt('');
setPromptEditedByUser(false);
}
const mediaSurface = homeMediaSurfaceForChipId(chip.id);
if (mediaSurface) {
const composer = buildHomeMediaComposer(
Expand Down Expand Up @@ -2504,6 +2574,7 @@ export function HomeView({
...pluginOptions,
suppressPromptUpdate: promptSeed === null,
deferApply: true,
promptSeedKind: promptSeed === null ? null : 'web-clone',
});
} else {
requestActivePlugin(record, undefined, pluginOptions);
Expand Down
107 changes: 103 additions & 4 deletions apps/web/tests/components/HomeView.web-clone-tracking.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
// project_kind=web_clone on project_create_result.

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

import { HomeView } from '../../src/components/HomeView';
import { I18nProvider } from '../../src/i18n';
import { writeHomeGuideStage } from '../../src/components/home-hero/firstRunGuide';
import { homeHeroPromptText, setHomeHeroPrompt } from '../helpers/home-hero-lexical';

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

Expand Down Expand Up @@ -67,11 +68,31 @@ const WEB_CLONE_BASE = {
},
};

const WEB_PROTOTYPE_BASE = {
...WEB_CLONE_BASE,
id: 'example-web-prototype',
title: 'Web Prototype',
source: '/tmp/web-prototype',
fsPath: '/tmp/web-prototype',
manifest: {
...WEB_CLONE_BASE.manifest,
name: 'example-web-prototype',
title: 'Web Prototype',
description: 'Create an interactive web prototype.',
tags: ['prototype'],
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create an interactive web prototype.' },
},
},
};

function stubPlugins() {
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
const href = typeof url === 'string' ? url : url.toString();
if (href === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_CLONE_BASE] }), {
return new Response(JSON.stringify({ plugins: [WEB_CLONE_BASE, WEB_PROTOTYPE_BASE] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
Expand All @@ -83,9 +104,9 @@ function stubPlugins() {
}));
}

function renderHome() {
function renderHome(locale: 'en' | 'zh-CN' = 'en') {
return render(
<I18nProvider initial="en">
<I18nProvider initial={locale}>
<HomeView
projects={[]}
onSubmit={() => undefined}
Expand Down Expand Up @@ -113,6 +134,84 @@ async function pickHomeTemplate(id: string) {
}

describe('web-clone example-card tracking', () => {
it('clears the untouched Website-clone URL scaffold when another type tab is selected', async () => {
writeHomeGuideStage('done');
stubPlugins();
renderHome('zh-CN');

await pickHomeTemplate('web-clone');
await waitFor(() => {
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
});

await pickHomeTemplate('prototype');
await waitFor(() => {
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
});
});

it('preserves a Website-clone URL after the user edits the scaffold', async () => {
writeHomeGuideStage('done');
stubPlugins();
renderHome('zh-CN');

await pickHomeTemplate('web-clone');
await waitFor(() => {
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
});

setHomeHeroPrompt('想要复刻的网站链接:https://example.com');
await act(async () => {
await Promise.resolve();
});
await pickHomeTemplate('prototype');

await waitFor(() => {
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:https://example.com');
});
});

it('keeps the Website-clone scaffold system-owned across an unmount and remount', async () => {
writeHomeGuideStage('done');
stubPlugins();
const firstMount = renderHome('zh-CN');

await pickHomeTemplate('web-clone');
await waitFor(() => {
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
expect(JSON.parse(window.localStorage.getItem('open-design:home-composer:chip')!))
.toMatchObject({ chipId: 'web-clone', promptSeedKind: 'web-clone' });
});

firstMount.unmount();
renderHome('zh-CN');
await waitFor(() => {
expect(homeHeroPromptText()).toBe('想要复刻的网站链接:');
});
await pickHomeTemplate('prototype');

await waitFor(() => {
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
});
});

it('cleans up a Website-clone scaffold leaked into another persisted type by the old behavior', async () => {
writeHomeGuideStage('done');
stubPlugins();
window.localStorage.setItem('open-design:home-composer:prompt', '想要复刻的网站链接:');
window.localStorage.setItem('open-design:home-composer:chip', JSON.stringify({
chipId: 'prototype',
pluginId: 'example-web-prototype',
projectKind: 'prototype',
}));

renderHome('zh-CN');

await waitFor(() => {
expect(screen.getByTestId('home-hero-input').textContent).toBe('');
});
});

it('renders the Website-clone examples as text prompt cards (no plugin preview / no remix)', async () => {
writeHomeGuideStage('done');
stubPlugins();
Expand Down
Loading