Skip to content

Commit a444fd0

Browse files
committed
fix(web): preserve home prompt template metadata
1 parent b084ab0 commit a444fd0

4 files changed

Lines changed: 290 additions & 13 deletions

File tree

apps/web/src/components/HomeView.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import { useWorkspaceInvalidation } from '../collab/workspace-events';
116116
import { useWorkspaceSnapshotActivation } from '../collab/workspace-snapshot-activation';
117117
import {
118118
buildHomeMediaComposer,
119+
homeMediaInputsAfterTemplateChange,
119120
homeMediaSurfaceForChipId,
120121
metadataForHomeMediaComposer,
121122
normalizeHomeMediaInputs,
@@ -2144,9 +2145,24 @@ export function HomeView({
21442145

21452146
function updateActiveInputs(next: Record<string, unknown>) {
21462147
if (!active) return;
2147-
const normalized = active.mediaSurface
2148-
? normalizeHomeMediaInputs(active.mediaSurface, next, promptTemplates, elevenLabsVoices, composerImageModels)
2148+
const templateAwareNext = active.mediaSurface
2149+
? homeMediaInputsAfterTemplateChange(
2150+
active.mediaSurface,
2151+
active.inputs,
2152+
next,
2153+
promptTemplates,
2154+
composerImageModels,
2155+
)
21492156
: next;
2157+
const normalized = active.mediaSurface
2158+
? normalizeHomeMediaInputs(
2159+
active.mediaSurface,
2160+
templateAwareNext,
2161+
promptTemplates,
2162+
elevenLabsVoices,
2163+
composerImageModels,
2164+
)
2165+
: templateAwareNext;
21502166
const mediaComposer = active.mediaSurface
21512167
? buildHomeMediaComposer(active.mediaSurface, promptTemplates, normalized, elevenLabsVoices, {
21522168
elevenLabsVoiceWarning,
@@ -2710,7 +2726,11 @@ export function HomeView({
27102726
);
27112727
return;
27122728
}
2713-
submittedActive = { ...submittedActive, result, inputs: submittedPluginInputs };
2729+
// The applied snapshot intentionally uses the run-facing inputs with
2730+
// hidden footer fields stripped, but the composer must retain its full
2731+
// model/aspect state so a rejected or blocked create can retry with the
2732+
// same project metadata.
2733+
submittedActive = { ...submittedActive, result };
27142734
setActive(submittedActive);
27152735
}
27162736
// Reconcile each selected context against the serialized prompt text before
@@ -2768,7 +2788,11 @@ export function HomeView({
27682788
const submittedProjectKind =
27692789
submittedActive?.projectKind ?? fallbackProjectKind ?? projectKindForSkill(activeSkill) ?? 'other';
27702790
const submittedProjectMetadata = submittedActive?.mediaSurface
2771-
? metadataForHomeMediaComposer(submittedActive.mediaSurface, submittedActive.inputs, promptTemplates)
2791+
? metadataForHomeMediaComposer(
2792+
submittedActive.mediaSurface,
2793+
submittedApplyInputs,
2794+
promptTemplates,
2795+
)
27722796
: homeCreateProjectMetadata(
27732797
submittedProjectKind,
27742798
submittedActive?.inputs ?? null,

apps/web/src/components/home-hero/media-surfaces.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,30 @@ export function buildHomeMediaComposer(
5656
} = {},
5757
): HomeMediaComposerState {
5858
const imageModels = options.imageModels ?? IMAGE_MODELS;
59+
const defaultInputs = defaultInputsForSurface(surface, promptTemplates, imageModels);
60+
const seededInputs = {
61+
...defaultInputs,
62+
...seedInputs,
63+
};
64+
const templateAwareInputs = surface === 'image'
65+
? homeMediaInputsAfterTemplateChange(
66+
surface,
67+
seedInputs,
68+
{
69+
...seededInputs,
70+
template: validTemplateId(
71+
surface,
72+
stringValue(seededInputs.template),
73+
promptTemplates,
74+
),
75+
},
76+
promptTemplates,
77+
imageModels,
78+
)
79+
: seededInputs;
5980
const inputs = normalizeHomeMediaInputs(
6081
surface,
61-
{
62-
...defaultInputsForSurface(surface, promptTemplates),
63-
...seedInputs,
64-
},
82+
templateAwareInputs,
6583
promptTemplates,
6684
voiceOptions,
6785
imageModels,
@@ -88,14 +106,15 @@ export function normalizeHomeMediaInputs(
88106
): Record<string, unknown> {
89107
if (surface === 'image') {
90108
const ratio = validOption(stringValue(raw.ratio) || stringValue(raw.aspect), MEDIA_ASPECTS, '16:9');
109+
const rawModel = stringValue(raw.model);
91110
return {
92111
mediaKind: 'image',
93112
subject: stringValue(raw.subject) || 'a premium product concept',
94113
style: stringValue(raw.style) || 'premium product-studio, elegant composition, refined lighting, restrained color',
95114
aspect: ratio,
96115
template: validTemplateId(surface, stringValue(raw.template), promptTemplates),
97116
designSystem: stringValue(raw.designSystem) || 'the active project design system',
98-
model: validOption(stringValue(raw.model), imageModels.map((m) => m.id), DEFAULT_IMAGE_MODEL),
117+
model: validImageModel(rawModel, imageModels) ? rawModel : DEFAULT_IMAGE_MODEL,
99118
ratio,
100119
resolution: validOption(stringValue(raw.resolution), MEDIA_RESOLUTIONS, DEFAULT_MEDIA_RESOLUTION),
101120
};
@@ -191,9 +210,14 @@ export function metadataForHomeMediaComposer(
191210
// run aligned with the model shown in the Home composer.
192211
if (surface === 'image') {
193212
const imageModel = stringValue(inputs.model);
213+
const selectedAspect = stringValue(inputs.aspect);
214+
const imageAspect = template && validImageTemplateAspect(selectedAspect)
215+
? selectedAspect
216+
: null;
194217
return {
195218
kind: 'image',
196219
...(imageModel ? { imageModel } : {}),
220+
...(imageAspect ? { imageAspect } : {}),
197221
...(promptTemplate ? { promptTemplate } : {}),
198222
};
199223
}
@@ -209,6 +233,38 @@ export function metadataForHomeMediaComposer(
209233
};
210234
}
211235

236+
export function homeMediaInputsAfterTemplateChange(
237+
surface: HomeComposerMediaSurface,
238+
previousInputs: Record<string, unknown>,
239+
nextInputs: Record<string, unknown>,
240+
promptTemplates: PromptTemplateSummary[],
241+
imageModels: MediaModel[] = IMAGE_MODELS,
242+
): Record<string, unknown> {
243+
if (
244+
surface !== 'image'
245+
|| stringValue(previousInputs.template) === stringValue(nextInputs.template)
246+
) {
247+
return nextInputs;
248+
}
249+
250+
const template = promptTemplates.find(
251+
(item) => item.surface === 'image' && item.id === stringValue(nextInputs.template),
252+
);
253+
if (!template) return nextInputs;
254+
255+
const model = validImageModel(template.model, imageModels)
256+
? template.model
257+
: null;
258+
const aspect = validImageTemplateAspect(template.aspect)
259+
? template.aspect
260+
: null;
261+
return {
262+
...nextInputs,
263+
...(model ? { model } : {}),
264+
...(aspect ? { aspect, ratio: aspect } : {}),
265+
};
266+
}
267+
212268
export function templatesForHomeMediaSurface(
213269
surface: HomeComposerMediaSurface,
214270
promptTemplates: PromptTemplateSummary[],
@@ -309,13 +365,22 @@ function queryTemplateForSurface(surface: HomeComposerMediaSurface, inputs: Reco
309365
function defaultInputsForSurface(
310366
surface: HomeComposerMediaSurface,
311367
promptTemplates: PromptTemplateSummary[],
368+
imageModels: MediaModel[] = IMAGE_MODELS,
312369
): Record<string, unknown> {
313370
if (surface === 'image') {
371+
const template = templatesForHomeMediaSurface(surface, promptTemplates)[0] ?? null;
372+
const model = template && validImageModel(template.model, imageModels)
373+
? template.model
374+
: DEFAULT_IMAGE_MODEL;
375+
const aspect = template && validImageTemplateAspect(template.aspect)
376+
? template.aspect
377+
: '16:9';
314378
return {
315-
template: firstTemplateId(surface, promptTemplates),
379+
template: template?.id ?? NO_TEMPLATE_PLACEHOLDER,
316380
designSystem: 'the active project design system',
317-
model: DEFAULT_IMAGE_MODEL,
318-
ratio: '16:9',
381+
model,
382+
aspect,
383+
ratio: aspect,
319384
resolution: DEFAULT_MEDIA_RESOLUTION,
320385
};
321386
}
@@ -337,6 +402,22 @@ function defaultInputsForSurface(
337402
return { text: 'the user\'s brief', audioType: 'speech', model: defaultHomeAudioModel('speech'), duration: 10 };
338403
}
339404

405+
function validImageModel(
406+
model: string | undefined,
407+
imageModels: MediaModel[],
408+
): model is string {
409+
return Boolean(
410+
model
411+
&& (imageModels.some((item) => item.id === model) || model.startsWith('aihubmix-')),
412+
);
413+
}
414+
415+
function validImageTemplateAspect(
416+
aspect: string | undefined,
417+
): aspect is (typeof MEDIA_ASPECTS)[number] {
418+
return Boolean(aspect && (MEDIA_ASPECTS as readonly string[]).includes(aspect));
419+
}
420+
340421
function stringField(name: string, label: string, placeholder?: string): InputFieldSpec {
341422
return {
342423
name,

apps/web/tests/components/HomeView.media-options.test.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,66 @@ describe('HomeView media composer options', () => {
301301
});
302302
});
303303

304+
it('submits the image prompt template model and aspect from the Home entry', async () => {
305+
stubFetch();
306+
const onSubmit = vi.fn();
307+
const portraitTemplate: PromptTemplateSummary = {
308+
...PROMPT_TEMPLATES[0]!,
309+
aspect: '3:4',
310+
};
311+
renderHome({ onSubmit, promptTemplates: [portraitTemplate] });
312+
313+
await clickHomeRailChip('image');
314+
await setHomePrompt('Create a portrait campaign image.');
315+
await submitHome();
316+
317+
await waitFor(() => {
318+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
319+
projectMetadata: expect.objectContaining({
320+
imageModel: 'gpt-image-2',
321+
imageAspect: '3:4',
322+
promptTemplate: expect.objectContaining({ id: 'image-product' }),
323+
}),
324+
}));
325+
});
326+
});
327+
328+
it('retains image template metadata when a rejected create is retried', async () => {
329+
stubFetch();
330+
const onSubmit = vi.fn()
331+
.mockResolvedValueOnce(false)
332+
.mockResolvedValueOnce(true);
333+
const portraitTemplate: PromptTemplateSummary = {
334+
...PROMPT_TEMPLATES[0]!,
335+
aspect: '3:4',
336+
};
337+
renderHome({ onSubmit, promptTemplates: [portraitTemplate] });
338+
339+
await clickHomeRailChip('image');
340+
await setHomePrompt('Create a retryable portrait image.');
341+
await submitHome();
342+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
343+
await submitHome();
344+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
345+
346+
for (const [payload] of onSubmit.mock.calls) {
347+
expect(payload).toEqual(expect.objectContaining({
348+
projectMetadata: expect.objectContaining({
349+
imageModel: 'gpt-image-2',
350+
imageAspect: '3:4',
351+
}),
352+
}));
353+
}
354+
});
355+
304356
it('updates submitted template metadata after media templates load', async () => {
305357
stubFetch();
306358
const onSubmit = vi.fn();
307359
const props = homeProps({ onSubmit, promptTemplates: [] });
360+
const portraitTemplate: PromptTemplateSummary = {
361+
...PROMPT_TEMPLATES[0]!,
362+
aspect: '3:4',
363+
};
308364
const view = render(<HomeView {...props} />);
309365

310366
await clickHomeRailChip('image');
@@ -320,12 +376,14 @@ describe('HomeView media composer options', () => {
320376
});
321377

322378
onSubmit.mockClear();
323-
view.rerender(<HomeView {...props} promptTemplates={PROMPT_TEMPLATES} />);
379+
view.rerender(<HomeView {...props} promptTemplates={[portraitTemplate]} />);
324380
await submitHome();
325381

326382
await waitFor(() => {
327383
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
328384
projectMetadata: expect.objectContaining({
385+
imageModel: 'gpt-image-2',
386+
imageAspect: '3:4',
329387
promptTemplate: expect.objectContaining({ id: 'image-product' }),
330388
}),
331389
}));

0 commit comments

Comments
 (0)