Skip to content

Commit 70c70ff

Browse files
elihahah666qiongyu1999lefarcen
authored
feat(web): replicate-first Use menu + send-button attention pulse (#4059)
* feat(web): lead the plugin detail Use menu with Replicate this content The split-button menu only exists when the plugin ships an example query, and reproducing the previewed result is what most users open it for — so the replicate option now comes first and structure-only Use drops to second. * feat(web): pulse the send button after a plugin Use or example-prompt pick Choosing Use / Use with query in the plugin detail modal, or clicking an example-prompt card, seeds the composer and returns the user to it — but nothing signals that sending is the next step. Flash the send button twice (house ease-out, 0.25s delay so the scroll-back settles first, none under prefers-reduced-motion) via a new HomeHeroHandle.pulseSend() fired from routePluginUse and useExamplePlugin. * feat(web): replicate-content as the primary detail CTA + sheen-style send pulse - The detail modal's primary Use button now reads (and acts as) 'Replicate this content' when the plugin ships an example query; structure-only Use moves behind the caret menu. Shared via pluginUsePrimaryAction so all four detail variants agree. - The send-button attention effect drops the ring+scale flash for a soft light sweep crossing the button twice — same trigger points, same reduced-motion opt-out. - Plain structure-only Use now pulses only when the composer is actually submittable (draft text or staged files); flashing a disabled send button pointed at a dead end. * feat(web): first-run guidance trail — prototype chip and first preset card sheen Brand-new users (no projects, fresh storage) get a sheen pulse on the Prototype type chip ~1s after Home settles; picking any type chip advances a persisted stage and the first example-prompt card pulses once when the chip's presets render, completing the trail (the existing send-button pulse covers the final beat after a card pick). Users with projects have the stage completed silently so the trail never shows. The send-button sheen generalizes into a shared .home-hero__attention-sheen class (accent-tinted glint on light chip / card surfaces), still skipped under prefers-reduced-motion. * fix(web): gate the first-run guide on project loading completing firstRunGuide was true whenever projects was the initial empty array, so the trail could arm for an existing user whose projects were still loading. Make the prop tri-state (undefined while loading): the guide neither arms the chip pulse nor silently completes the stage until the projects answer is known. * fix(web): close any in-flight guide stage for existing users + gate the card pulse A chip pick during the projects-loading window can advance the guide stage to 'card' before firstRunGuide resolves; if the user then turns out to have projects, the stage lingered and the beat-2 effect (not gated on firstRunGuide) could sheen the first preset card for a non-new user. The firstRunGuide === false branch now completes ANY unfinished stage, and the card-pulse effect requires firstRunGuide === true. * fix(web): pulse Send from the static prompt-example path too The fallback prompt-example cards (rendered when a chip has no example plugins) seed and focus the composer entirely inside HomeHero, so they never reached the HomeView-routed pulseSend(). Extract a local triggerSendAttention() shared by the imperative handle and usePromptExample, with a focused spec clicking a home-hero-prompt-example card. * fix(test): null-guard the first prompt-example card lookup tsc (noUncheckedIndexedAccess) rejects passing exampleCards[0] straight to fireEvent.click; guard explicitly. * fix(web): gate the Send pulse on the composer actually being submittable usePlugin() now resolves a boolean: whether the bound plugin left the composer submittable (inputs valid, apply not failed/superseded). All three pulse call sites (use-with-query, plain Use, example preset pick) consult it, so a plugin with required inputs and no defaults routes the user to the inputs form without a misleading flash on the disabled Send button. Spec: required-input plugin via detail-modal replicate → Send stays disabled and unsheened. * fix(web): carry the guide's beat 2 through the static prompt-example fallback A chip with no plugin presets renders static prompt-example cards, but the beat-2 effect only watched filteredExamplePlugins — a fresh user on that path never got the card pulse and the persisted stage stuck at 'card'. Either card surface now satisfies beat 2, the first static prompt-example button carries the sheen, and a regression spec covers the presetless-chip path. * test(e2e): route freeform Use through the Use-plugin-only menu option The replicate-first menu change makes the detail modal's primary Use button seed the example query for query-bearing starters, so the structure-only freeform path now lives in the caret menu's 'Use plugin only' option. Update the P0 critical 'keeps the prompt freeform' spec to open the caret menu and click that option, matching the shipped behavior. * test(e2e): route the remaining freeform Use cases through the menu option The replicate-first primary CTA also seeds the prompt for query-bearing starters rendered via PluginExampleDetail/PreviewModal, so the neighboring [P1] 'Use plugin from the details modal' case (an html-preview fixture with an example query) that clicked the primary button and asserted an empty composer would fail once it runs outside the @critical gate. Route it through the caret menu's 'Use plugin only' option like the P0 case, and correct the now-stale 'no pre-fill' comments on the chip-only and use-with-query cases. --------- Co-authored-by: qiongyu1999 <2694684348@qq.com> Co-authored-by: lefarcen <ontf116@gmail.com>
1 parent 52a4d65 commit 70c70ff

13 files changed

Lines changed: 652 additions & 55 deletions

apps/web/src/components/HomeHero.tsx

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
localizeSkillName,
6969
} from '../i18n/content';
7070
import { PreviewSurface } from './plugins-home/cards/PreviewSurface';
71+
import { readHomeGuideStage, writeHomeGuideStage } from './home-hero/firstRunGuide';
7172
import { curatedPluginPriorityForChip } from './plugins-home/curatedPriority';
7273
import { sortByVisualAppeal } from './plugins-home/visualScore';
7374
import { applyFacetSelection } from './plugins-home/facets';
@@ -93,6 +94,9 @@ export interface HomeHeroSubmitHandler {
9394
export interface HomeHeroHandle {
9495
focus(): void;
9596
focusEnd(): void;
97+
// Flash the send button twice — fired after a plugin Use action or an
98+
// example-prompt card seeds the composer, to pull the eye to the next step.
99+
pulseSend(): void;
96100
}
97101

98102
export interface ExamplePromptInfo {
@@ -103,6 +107,11 @@ export interface ExamplePromptInfo {
103107

104108
interface Props {
105109
active?: boolean;
110+
// Arms the first-run guidance trail (prototype chip → first preset
111+
// card sheen). Tri-state: true = brand-new user (no projects), false =
112+
// existing user, undefined = projects still loading — the guide neither
113+
// arms nor completes until the answer is known.
114+
firstRunGuide?: boolean;
106115
prompt: string;
107116
onPromptChange: (value: string) => void;
108117
onSubmit: HomeHeroSubmitHandler;
@@ -224,6 +233,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
224233
prompt,
225234
onPromptChange,
226235
onSubmit,
236+
firstRunGuide,
227237
sessionMode = 'design',
228238
onSessionModeChange,
229239
activePluginTitle,
@@ -290,6 +300,13 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
290300
const [hoveredPlugin, setHoveredPlugin] = useState<InstalledPluginRecord | null>(null);
291301
const [dragActive, setDragActive] = useState(false);
292302
const [shortcutsOpen, setShortcutsOpen] = useState(false);
303+
// Two-flash attention pulse on the send button; armed via the
304+
// imperative `pulseSend()` handle, cleared when the animation ends.
305+
const [sendAttention, setSendAttention] = useState(false);
306+
// First-run guidance trail (see home-hero/firstRunGuide.ts): which rail
307+
// chip is pulsing, and whether the first example-prompt card is pulsing.
308+
const [guidePulseChipId, setGuidePulseChipId] = useState<string | null>(null);
309+
const [guidePulseFirstPreset, setGuidePulseFirstPreset] = useState(false);
293310
// Selected second-level sub-category slug (Prototype / Slide deck rail).
294311
// Local-only: it filters the example-prompt cards below the rail. It never
295312
// binds a plugin or stamps an active badge.
@@ -529,12 +546,57 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
529546
applyFacetSelection(pool, { category: activeChipId, subcategory: selectedSubcategory }),
530547
);
531548
}, [activeExamplePlugins, activeChipId, selectedSubcategory, pluginOptions]);
549+
550+
// First-run guide, beat 1: pulse the Prototype chip for brand-new users.
551+
// The settle delay lets the hero finish its entrance before the sheen.
552+
useEffect(() => {
553+
if (firstRunGuide !== true) return;
554+
if (readHomeGuideStage() !== 'chip') return;
555+
const arm = window.setTimeout(() => setGuidePulseChipId('prototype'), 900);
556+
const disarm = window.setTimeout(() => setGuidePulseChipId(null), 3600);
557+
return () => {
558+
window.clearTimeout(arm);
559+
window.clearTimeout(disarm);
560+
};
561+
}, [firstRunGuide]);
562+
563+
// Users with existing projects never see the trail — complete ANY
564+
// unfinished stage silently. A chip pick during the loading window can
565+
// move the stage to 'card' before we know the user is not new, so 'chip'
566+
// alone is not enough to close off.
567+
useEffect(() => {
568+
if (firstRunGuide !== false) return;
569+
if (readHomeGuideStage() !== 'done') writeHomeGuideStage('done');
570+
}, [firstRunGuide]);
571+
532572
const activePromptExamples = useMemo(
533573
() => activeChipId && activeExamplePlugins.length === 0
534574
? homeHeroChipPromptExamples(activeChipId, locale)
535575
: [],
536576
[activeChipId, activeExamplePlugins.length, locale],
537577
);
578+
579+
// Beat 2: once the picked chip's example cards render, pulse the first
580+
// card exactly once, then the trail is done (the send pulse takes over
581+
// after a card pick).
582+
useEffect(() => {
583+
if (firstRunGuide !== true) return;
584+
if (readHomeGuideStage() !== 'card') return;
585+
// Either card surface counts: plugin preset tiles, or the static
586+
// prompt-example fallback a presetless chip renders instead.
587+
const hasExampleCards =
588+
filteredExamplePlugins.length > 0 || activePromptExamples.length > 0;
589+
if (!activeChipId || !hasExampleCards) return;
590+
const arm = window.setTimeout(() => {
591+
setGuidePulseFirstPreset(true);
592+
writeHomeGuideStage('done');
593+
}, 500);
594+
const disarm = window.setTimeout(() => setGuidePulseFirstPreset(false), 3200);
595+
return () => {
596+
window.clearTimeout(arm);
597+
window.clearTimeout(disarm);
598+
};
599+
}, [firstRunGuide, activeChipId, filteredExamplePlugins.length, activePromptExamples.length]);
538600
const authoringLayoutActive =
539601
activeChipId === 'create-plugin' || pendingChipId === 'create-plugin';
540602
const promptMaxHeight = authoringLayoutActive
@@ -634,6 +696,17 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
634696
return () => window.removeEventListener('keydown', onKeyDown);
635697
}, [previewHomeFileKey]);
636698

699+
// Shared by the imperative pulseSend() handle (plugin Use / preset picks
700+
// routed through HomeView) and the component-internal static
701+
// prompt-example path — every "composer just got seeded" flow shows the
702+
// same Send cue.
703+
function triggerSendAttention() {
704+
// Drop the class for a frame so a pulse requested mid-animation
705+
// restarts instead of being swallowed.
706+
setSendAttention(false);
707+
requestAnimationFrame(() => setSendAttention(true));
708+
}
709+
637710
useImperativeHandle(
638711
ref,
639712
(): HomeHeroHandle => ({
@@ -643,6 +716,9 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
643716
focusEnd() {
644717
editorRef.current?.focus();
645718
},
719+
pulseSend() {
720+
triggerSendAttention();
721+
},
646722
}),
647723
[],
648724
);
@@ -804,6 +880,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
804880
editorRef.current?.setText(example);
805881
setSelectedIndex(0);
806882
requestAnimationFrame(() => editorRef.current?.focus());
883+
triggerSendAttention();
807884
}
808885

809886
function pickExamplePluginPreset(record: InstalledPluginRecord, chipId: string, promptText: string) {
@@ -836,6 +913,12 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
836913
element: 'task_chip',
837914
chip_id: chip.id,
838915
});
916+
// First chip pick completes the guide's first beat; the preset-card
917+
// pulse arms once the example cards for this chip render.
918+
if (readHomeGuideStage() === 'chip') {
919+
writeHomeGuideStage('card');
920+
setGuidePulseChipId(null);
921+
}
839922
onPickChip(chip);
840923
}
841924

@@ -1395,9 +1478,10 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
13951478
) : null}
13961479
<button
13971480
type="button"
1398-
className="home-hero__submit od-tooltip"
1481+
className={`home-hero__submit od-tooltip${sendAttention ? ' home-hero__attention-sheen' : ''}`}
13991482
data-testid="home-hero-submit"
14001483
onClick={onSubmit}
1484+
onAnimationEnd={() => setSendAttention(false)}
14011485
disabled={!canSubmit}
14021486
title={canSubmit ? t('homeHero.run') : t('homeHero.typeSomethingToRun')}
14031487
data-tooltip={canSubmit ? t('homeHero.run') : t('homeHero.typeSomethingToRun')}
@@ -1452,6 +1536,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
14521536
pluginsLoading={pluginsLoading}
14531537
onPickChip={handlePickTaskChip}
14541538
variant="tabs"
1539+
pulseChipId={guidePulseChipId}
14551540
>
14561541
<ShortcutsMenu
14571542
activeChipId={activeChipId}
@@ -1505,6 +1590,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
15051590
pendingPluginId={pendingPluginId}
15061591
locale={locale}
15071592
onPick={pickExamplePluginPreset}
1593+
pulseFirstPreset={guidePulseFirstPreset}
15081594
/>
15091595
) : activePromptExamples.length > 0 ? (
15101596
<div
@@ -1515,11 +1601,11 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
15151601
{t('homeHero.promptExamples')}
15161602
</div>
15171603
<div className="home-hero__prompt-examples-grid">
1518-
{activePromptExamples.map((example) => (
1604+
{activePromptExamples.map((example, index) => (
15191605
<button
15201606
key={example}
15211607
type="button"
1522-
className="home-hero__prompt-example"
1608+
className={`home-hero__prompt-example${guidePulseFirstPreset && index === 0 ? ' home-hero__attention-sheen' : ''}`}
15231609
data-testid="home-hero-prompt-example"
15241610
onClick={() => usePromptExample(example)}
15251611
>
@@ -1575,13 +1661,16 @@ function PluginPromptPresets({
15751661
onPick,
15761662
pendingPluginId,
15771663
plugins,
1664+
pulseFirstPreset = false,
15781665
}: {
15791666
activePluginId: string | null;
15801667
chipId: string;
15811668
locale: Locale;
15821669
onPick: (record: InstalledPluginRecord, chipId: string, promptText: string) => void;
15831670
pendingPluginId: string | null;
15841671
plugins: InstalledPluginRecord[];
1672+
// First-run guide: the first card carries the attention sheen.
1673+
pulseFirstPreset?: boolean;
15851674
}) {
15861675
const { t } = useI18n();
15871676
return (
@@ -1593,7 +1682,7 @@ function PluginPromptPresets({
15931682
{t('homeHero.promptExamples')}
15941683
</div>
15951684
<div className="home-hero__plugin-presets" role="list">
1596-
{plugins.map((record) => (
1685+
{plugins.map((record, index) => (
15971686
<PluginPromptPresetCard
15981687
key={record.id}
15991688
chipId={chipId}
@@ -1602,6 +1691,7 @@ function PluginPromptPresets({
16021691
active={activePluginId === record.id}
16031692
pending={pendingPluginId === record.id}
16041693
disabled={pendingPluginId !== null}
1694+
pulse={pulseFirstPreset && index === 0}
16051695
onPick={onPick}
16061696
/>
16071697
))}
@@ -1617,6 +1707,7 @@ function PluginPromptPresetCard({
16171707
locale,
16181708
onPick,
16191709
pending,
1710+
pulse = false,
16201711
record,
16211712
}: {
16221713
active: boolean;
@@ -1625,6 +1716,7 @@ function PluginPromptPresetCard({
16251716
locale: Locale;
16261717
onPick: (record: InstalledPluginRecord, chipId: string, promptText: string) => void;
16271718
pending: boolean;
1719+
pulse?: boolean;
16281720
record: InstalledPluginRecord;
16291721
}) {
16301722
// Example-prompt preset tiles are thumbnails too — prefer the cheap baked
@@ -1638,7 +1730,7 @@ function PluginPromptPresetCard({
16381730
return (
16391731
<button
16401732
type="button"
1641-
className={`home-hero__plugin-preset${active ? ' is-active' : ''}${pending ? ' is-pending' : ''}`}
1733+
className={`home-hero__plugin-preset${active ? ' is-active' : ''}${pending ? ' is-pending' : ''}${pulse ? ' home-hero__attention-sheen' : ''}`}
16421734
data-testid="home-hero-plugin-preset"
16431735
data-plugin-id={record.id}
16441736
role="listitem"
@@ -2438,6 +2530,8 @@ interface RailGroupProps {
24382530
pluginsLoading: boolean;
24392531
onPickChip: (chip: HomeHeroChip) => void;
24402532
variant?: 'rail' | 'tabs';
2533+
// First-run guide: this chip carries the attention sheen.
2534+
pulseChipId?: string | null;
24412535
children?: ReactNode;
24422536
}
24432537

@@ -2449,6 +2543,7 @@ function RailGroup({
24492543
pluginsLoading,
24502544
onPickChip,
24512545
variant = 'rail',
2546+
pulseChipId = null,
24522547
children,
24532548
}: RailGroupProps) {
24542549
const t = useT();
@@ -2474,6 +2569,7 @@ function RailGroup({
24742569
: ['home-hero__rail-chip', `home-hero__rail-chip--${group}`];
24752570
if (isActive) cls.push('is-active');
24762571
if (isPending) cls.push('is-pending');
2572+
if (pulseChipId === chip.id) cls.push('home-hero__attention-sheen');
24772573
return (
24782574
<button
24792575
key={chip.id}

0 commit comments

Comments
 (0)