From 41bbd1e16b4ea619fef62543c85b3b15bcb64e2a Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 9 Jul 2026 22:00:23 +0200 Subject: [PATCH] feat: refine candidate handling in generation commands and update UI interactions --- commands/generation.test.ts | 32 ++++++++++++-- commands/generation.ts | 42 +++++++++++-------- core/asset-provenance.ts | 2 +- ...026-07-09-product-ux-architecture-audit.md | 12 ++++-- .../GenerateActionControls.tsx | 22 +++++----- 5 files changed, 73 insertions(+), 37 deletions(-) diff --git a/commands/generation.test.ts b/commands/generation.test.ts index 7c57c98..46e4413 100644 --- a/commands/generation.test.ts +++ b/commands/generation.test.ts @@ -36,7 +36,15 @@ describe("generation commands", () => { }); test("applies candidates as top-level layers", () => { - const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1") }); + const withRemainingCandidate = generationAddCandidateCommand.execute( + { state: documentWithSourceLayer() }, + { candidate: generationCandidate("candidate-2") }, + ); + const withSelectedCandidate = generationAddCandidateCommand.execute( + { state: withRemainingCandidate }, + { candidate: generationCandidate("candidate-1") }, + ); + const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "split" }); const next = generationApplyCandidateAsLayerCommand.execute( { state }, @@ -52,11 +60,23 @@ describe("generation commands", () => { }); expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer"); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] }); - expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" }); + expect(next.editor.generation).toEqual({ + candidates: [generationCandidate("candidate-2")], + selectedCandidateId: "candidate-2", + compareMode: "split", + }); }); test("replaces source asset pixels for inpaint candidates", () => { - const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1", true) }); + const withRemainingCandidate = generationAddCandidateCommand.execute( + { state: documentWithSourceLayer() }, + { candidate: generationCandidate("candidate-2", true) }, + ); + const withSelectedCandidate = generationAddCandidateCommand.execute( + { state: withRemainingCandidate }, + { candidate: generationCandidate("candidate-1", true) }, + ); + const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "before" }); const next = generationReplaceCandidatePixelsCommand.execute( { state }, @@ -75,7 +95,11 @@ describe("generation commands", () => { }, }); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] }); - expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" }); + expect(next.editor.generation).toEqual({ + candidates: [generationCandidate("candidate-2", true)], + selectedCandidateId: "candidate-2", + compareMode: "before", + }); }); }); diff --git a/commands/generation.ts b/commands/generation.ts index ba63559..c0f6832 100644 --- a/commands/generation.ts +++ b/commands/generation.ts @@ -28,7 +28,6 @@ export type GenerationApplyCandidateAsLayerPayload = { candidateId: string; assetId: AssetId; layerId: LayerId; - variant?: boolean; }; export type GenerationReplaceCandidatePixelsPayload = { @@ -105,18 +104,13 @@ export const generationRemoveCandidateCommand: Command candidate.id !== payload.candidateId); - if (candidates.length === state.editor.generation.candidates.length) return state; - const selectedCandidateId = state.editor.generation.selectedCandidateId === payload.candidateId ? candidates[0]?.id : state.editor.generation.selectedCandidateId; + const generation = removeGenerationCandidate(state.editor.generation, payload.candidateId); + if (generation === state.editor.generation) return state; return { ...state, editor: { ...state.editor, - generation: { - candidates, - selectedCandidateId, - compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result", - }, + generation, }, }; }, @@ -149,16 +143,16 @@ export const generationApplyCandidateAsLayerCommand: Command candidate.id === candidateId); + if (removedIndex < 0) return generation; + + const candidates = generation.candidates.filter((candidate) => candidate.id !== candidateId); + const selectionStillExists = generation.selectedCandidateId + ? candidates.some((candidate) => candidate.id === generation.selectedCandidateId) + : false; + const selectedCandidateId = selectionStillExists + ? generation.selectedCandidateId + : candidates[Math.min(removedIndex, candidates.length - 1)]?.id; + + return { + candidates, + selectedCandidateId, + compareMode: candidates.length > 0 ? generation.compareMode : "result", + }; } function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance { diff --git a/core/asset-provenance.ts b/core/asset-provenance.ts index 4b476b0..5af5858 100644 --- a/core/asset-provenance.ts +++ b/core/asset-provenance.ts @@ -3,7 +3,7 @@ import type { AssetId, LayerId } from "./id"; export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint"; -export type GeneratedAssetAcceptance = "layer" | "variant-layer" | "replacement"; +export type GeneratedAssetAcceptance = "layer" | "replacement"; export type AssetGenerationProvenance = { kind: "generated"; diff --git a/docs/audits/2026-07-09-product-ux-architecture-audit.md b/docs/audits/2026-07-09-product-ux-architecture-audit.md index ffbb3e4..96f33ae 100644 --- a/docs/audits/2026-07-09-product-ux-architecture-audit.md +++ b/docs/audits/2026-07-09-product-ux-architecture-audit.md @@ -107,22 +107,28 @@ These surfaces do not form a legible sequence. “Generate” is closer to a wor Redesign implication: generation should open an operation workspace with a clear input stage and a result stage. The active canvas interaction inside that workspace can still be select, pan, paint-mask, or transform. -### P1 — accepting one candidate destroys the entire candidate session +### P1 — accepting one candidate destroys the entire candidate session — resolved 2026-07-09 Both “accept as layer” and “replace pixels” call `clearCommittedGenerationPreview`, which clears all candidates, not only the accepted candidate. This conflicts with the core use case of combining multiple model outputs. A user who generates several alternatives and accepts one loses the remaining comparison set. Redesign implication: accepting a candidate should mark or remove only that candidate by default. The result tray should support keeping, pinning, multi-selecting, and clearing the session explicitly. -### P1 — up to twelve candidates are stored but only six are selectable +Resolution: both layer acceptance and masked-pixel replacement now remove only the committed candidate, select the nearest remaining candidate, and preserve the active comparison mode while results remain. + +### P1 — up to twelve candidates are stored but only six are selectable — resolved 2026-07-09 Generation state retains twelve candidates, while `CandidatePicker` renders only `candidates.slice(0, 6)`. Candidates seven through twelve have no visible selection path. This is a concrete interaction bug, not merely a styling concern. -### P1 — “variant” is only a renamed ordinary layer +Resolution: the picker now renders the complete bounded candidate set in a horizontally scrollable group. + +### P1 — “variant” is only a renamed ordinary layer — resolved 2026-07-09 “Accept variant” creates a standard top-level layer and records `variant-layer` provenance. There is no document-level variant set, linked source, stack semantics, exclusive visibility, or comparison group. The label promises more structure than the product provides. Redesign implication: either call this “Add as another layer” or introduce a real variant/result-set concept. A result tray can provide variant semantics without forcing them into the document tree prematurely. +Resolution: the duplicate variant action and `variant-layer` provenance value were removed. The UI now describes the real operation as “Add as layer”; refinement layers use the same honest layer acceptance semantics. + ### P1 — generation preconditions are not represented clearly The Generate button only requires a non-empty prompt. Image-to-image can proceed without a selected source image. Inpaint configuration is available without explaining or enforcing the required source layer and mask. Outpaint and mode-specific settings coexist regardless of current mode. diff --git a/view/bottom-controls/GenerateActionControls.tsx b/view/bottom-controls/GenerateActionControls.tsx index 2432c06..bd6fb69 100644 --- a/view/bottom-controls/GenerateActionControls.tsx +++ b/view/bottom-controls/GenerateActionControls.tsx @@ -79,8 +79,8 @@ export function GenerateActionControls({ document, selection, viewport, settings function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) { if (generation.candidates.length < 2) return null; return ( -
- {generation.candidates.slice(0, 6).map((candidate) => { +
+ {generation.candidates.map((candidate) => { const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id); return (