diff --git a/src/core/generation/generation-status.ts b/src/core/generation/generation-status.ts index 720d8bbb..a6fc0b64 100644 --- a/src/core/generation/generation-status.ts +++ b/src/core/generation/generation-status.ts @@ -12,6 +12,7 @@ export type GenerationConfig = { export type GenerationStatus = { text: string; tone?: "neutral" | "warning" | "error"; + onActivate?: () => void; }; export type GenerationStatusRequest = GenerationConfig & { diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index a8368e03..501d9a4f 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -39,6 +39,8 @@ export class GenerateToolbar extends BaseToolbar { private generateBtn: HTMLButtonElement | null = null; private generateError: HTMLElement | null = null; private generateNote: HTMLElement | null = null; + private generateNoteAction: HTMLButtonElement | null = null; + private generateGroup: HTMLElement | null = null; private modelBtn: HTMLButtonElement | null = null; private modelLabel: HTMLElement | null = null; private modelPopup: HTMLElement | null = null; @@ -92,10 +94,13 @@ export class GenerateToolbar extends BaseToolbar {
- - +
+ + + +
`; @@ -105,6 +110,8 @@ export class GenerateToolbar extends BaseToolbar { this.generateBtn = this.container.querySelector("[data-action='generate']"); this.generateError = this.container.querySelector("[data-generate-error]"); this.generateNote = this.container.querySelector("[data-generate-note]"); + this.generateNoteAction = this.container.querySelector("[data-generate-note-action]"); + this.generateGroup = this.container.querySelector("[data-generate-group]"); this.modelBtn = this.container.querySelector("[data-model-picker]"); this.modelLabel = this.container.querySelector("[data-model-label]"); this.modelPopup = this.container.querySelector("[data-model-popup]"); @@ -163,6 +170,11 @@ export class GenerateToolbar extends BaseToolbar { e.stopPropagation(); this.togglePopup(this.optionsPopup); }, { signal }); + this.generateNoteAction?.addEventListener("click", e => { + e.stopPropagation(); + const clipId = this.getSelectedClipId(); + if (clipId) this.edit.getGenerationStatus(clipId)?.onActivate?.(); + }, { signal }); } private requestGeneration(): void { @@ -417,22 +429,29 @@ export class GenerateToolbar extends BaseToolbar { const hasGenerator = this.edit.hasAssetGenerator(); this.generateBtn.hidden = !hasGenerator; const status = hasGenerator ? this.edit.getGenerationStatus(this.getSelectedClipId() ?? "") : undefined; - if (this.generateNote) { + this.generateGroup?.classList.toggle("has-status", status !== undefined); + if (this.generateNote && this.generateNoteAction) { + const actionable = status?.onActivate !== undefined; if (!hasGenerator) { this.generateNote.textContent = "Generates on render"; this.generateNote.title = "Rendering generates this from the prompt. Register an asset generator to preview it here."; delete this.generateNote.dataset["tone"]; - this.generateNote.hidden = false; } else if (status) { - this.generateNote.textContent = status.text; - this.generateNote.dataset["tone"] = status.tone ?? "neutral"; - this.generateNote.removeAttribute("title"); - this.generateNote.hidden = false; - } else { - this.generateNote.textContent = ""; - delete this.generateNote.dataset["tone"]; - this.generateNote.removeAttribute("title"); - this.generateNote.hidden = true; + const target = actionable ? this.generateNoteAction : this.generateNote; + target.textContent = status.text; + target.dataset["tone"] = status.tone ?? "neutral"; + target.removeAttribute("title"); + } + for (const [element, shown] of [ + [this.generateNote, !hasGenerator || (status !== undefined && !actionable)], + [this.generateNoteAction, actionable] + ] as const) { + if (!shown) { + element.textContent = ""; + delete element.dataset["tone"]; + element.removeAttribute("title"); + } + element.hidden = !shown; } } if (!hasGenerator) { @@ -480,6 +499,8 @@ export class GenerateToolbar extends BaseToolbar { this.generateBtn = null; this.generateError = null; this.generateNote = null; + this.generateNoteAction = null; + this.generateGroup = null; this.modelBtn = null; this.modelLabel = null; this.modelPopup = null; diff --git a/src/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css index 727ff4a4..31fc6711 100644 --- a/src/styles/ui/generate-toolbar.css +++ b/src/styles/ui/generate-toolbar.css @@ -88,11 +88,58 @@ display: none; } -.ss-ai-note[data-tone="warning"] { +.ss-ai-generate-group { + display: inline-flex; + align-items: center; + border-radius: 6px; + overflow: hidden; +} + +.ss-ai-generate-group.has-status > .ss-ai-generate-btn { + border-radius: 0; +} + +.ss-ai-generate-group.has-status > .ss-ai-note:not([hidden]) { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 10px; + background: rgba(255, 255, 255, 0.04); + border-left: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.ss-ai-note--action { + appearance: none; + border: 0; + font-family: inherit; + cursor: pointer; +} + +.ss-ai-generate-group.has-status > .ss-ai-note--action:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.ss-ai-generate-group.has-status > .ss-ai-note--action:active { + background: rgba(255, 255, 255, 0.2); +} + +.ss-ai-note--action:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.7); + outline-offset: -3px; +} + +.ss-ai-note[data-tone="warning"], +.ss-ai-generate-group.has-status > .ss-ai-note[data-tone="warning"]:not([hidden]) { color: #fcd34d; } -.ss-ai-note[data-tone="error"] { +.ss-ai-note[data-tone="error"], +.ss-ai-generate-group.has-status > .ss-ai-note[data-tone="error"]:not([hidden]) { color: #fca5a5; } diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 00abb027..3461d11b 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -583,6 +583,65 @@ describe("GenerateToolbar", () => { }); describe("host status", () => { + it("opens the host action from a status button without generating", () => { + const edit = createMockEdit(); + const onActivate = jest.fn(); + edit.getGenerationStatus.mockReturnValue({ text: "0.5 credits", onActivate }); + const { toolbar, container } = mountToolbar(edit); + const action = container.querySelector("[data-generate-note-action]"); + expect(action).not.toBeNull(); + expect(action?.hidden).toBe(false); + expect(action?.type).toBe("button"); + expect(action?.textContent).toBe("0.5 credits"); + expect(container.querySelector("[data-generate-note]")?.hidden).toBe(true); + action?.click(); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(edit.generateClip).not.toHaveBeenCalled(); + toolbar.dispose(); + }); + + it("uses the current action and clears it when status becomes passive or absent", () => { + const edit = createMockEdit(); + const first = jest.fn(); + const second = jest.fn(); + edit.getGenerationStatus.mockReturnValue({ text: "First", onActivate: first }); + const { toolbar, container } = mountToolbar(edit); + const action = container.querySelector("[data-generate-note-action]"); + expect(action).not.toBeNull(); + edit.getGenerationStatus.mockReturnValue({ text: "Second", onActivate: second }); + toolbar.show(0, 0); + action?.click(); + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + edit.getGenerationStatus.mockReturnValue({ text: "Read only" }); + toolbar.show(0, 0); + expect(action?.hidden).toBe(true); + expect(action?.textContent).toBe(""); + expect(container.querySelector("[data-generate-note]")?.hidden).toBe(false); + action?.click(); + expect(second).toHaveBeenCalledTimes(1); + edit.getGenerationStatus.mockReturnValue(undefined); + toolbar.show(0, 0); + expect(container.querySelector("[data-generate-note]")?.hidden).toBe(true); + expect(action?.hidden).toBe(true); + toolbar.dispose(); + }); + + it("keeps the status action available when an error blocks generation", () => { + const edit = createMockEdit(); + const onActivate = jest.fn(); + edit.getGenerationStatus.mockReturnValue({ text: "Add credits", tone: "error", onActivate }); + const { toolbar, container } = mountToolbar(edit); + const action = container.querySelector("[data-generate-note-action]"); + expect(action?.dataset["tone"]).toBe("error"); + expect(action?.disabled).toBe(false); + expect(container.querySelector("[data-action='generate']")?.disabled).toBe(true); + action?.click(); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(edit.generateClip).not.toHaveBeenCalled(); + toolbar.dispose(); + }); + it("keeps Generate and Enter blocked until an error status refresh settles", async () => { const edit = createMockEdit(); let generations = 0;