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 {
-
- Generates on render
+
+
+ Generates on render
+
+
`;
@@ -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;