Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/generation/generation-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type GenerationConfig = {
export type GenerationStatus = {
text: string;
tone?: "neutral" | "warning" | "error";
onActivate?: () => void;
};

export type GenerationStatusRequest = GenerationConfig & {
Expand Down
51 changes: 36 additions & 15 deletions src/core/ui/generate-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,10 +94,13 @@ export class GenerateToolbar extends BaseToolbar {
<button class="ss-media-toolbar-btn ss-ai-picker" data-options-picker type="button" aria-haspopup="dialog">Options</button>
<div class="ss-media-toolbar-popup ss-ai-options-popup" data-options-popup></div>
</div>
<button class="ss-media-toolbar-btn ss-ai-generate-btn" data-action="generate">
<span data-generate-label>Generate</span>
</button>
<span class="ss-ai-note" data-generate-note hidden>Generates on render</span>
<div class="ss-ai-generate-group" data-generate-group>
<button class="ss-media-toolbar-btn ss-ai-generate-btn" data-action="generate">
<span data-generate-label>Generate</span>
</button>
<span class="ss-ai-note" data-generate-note hidden>Generates on render</span>
<button type="button" class="ss-ai-note ss-ai-note--action" data-generate-note-action hidden></button>
</div>
<span class="ss-ai-error" data-generate-error hidden></span>
`;

Expand All @@ -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]");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 49 additions & 2 deletions src/styles/ui/generate-toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
59 changes: 59 additions & 0 deletions tests/generate-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[data-generate-note]")?.hidden).toBe(false);
action?.click();
expect(second).toHaveBeenCalledTimes(1);
edit.getGenerationStatus.mockReturnValue(undefined);
toolbar.show(0, 0);
expect(container.querySelector<HTMLElement>("[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<HTMLButtonElement>("[data-generate-note-action]");
expect(action?.dataset["tone"]).toBe("error");
expect(action?.disabled).toBe(false);
expect(container.querySelector<HTMLButtonElement>("[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;
Expand Down
Loading