Skip to content

Commit e9eb7ce

Browse files
authored
feat: allow hosts to activate generation status annotations (#199)
1 parent 920d853 commit e9eb7ce

4 files changed

Lines changed: 145 additions & 17 deletions

File tree

‎src/core/generation/generation-status.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type GenerationConfig = {
1212
export type GenerationStatus = {
1313
text: string;
1414
tone?: "neutral" | "warning" | "error";
15+
onActivate?: () => void;
1516
};
1617

1718
export type GenerationStatusRequest = GenerationConfig & {

‎src/core/ui/generate-toolbar.ts‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export class GenerateToolbar extends BaseToolbar {
3939
private generateBtn: HTMLButtonElement | null = null;
4040
private generateError: HTMLElement | null = null;
4141
private generateNote: HTMLElement | null = null;
42+
private generateNoteAction: HTMLButtonElement | null = null;
43+
private generateGroup: HTMLElement | null = null;
4244
private modelBtn: HTMLButtonElement | null = null;
4345
private modelLabel: HTMLElement | null = null;
4446
private modelPopup: HTMLElement | null = null;
@@ -92,10 +94,13 @@ export class GenerateToolbar extends BaseToolbar {
9294
<button class="ss-media-toolbar-btn ss-ai-picker" data-options-picker type="button" aria-haspopup="dialog">Options</button>
9395
<div class="ss-media-toolbar-popup ss-ai-options-popup" data-options-popup></div>
9496
</div>
95-
<button class="ss-media-toolbar-btn ss-ai-generate-btn" data-action="generate">
96-
<span data-generate-label>Generate</span>
97-
</button>
98-
<span class="ss-ai-note" data-generate-note hidden>Generates on render</span>
97+
<div class="ss-ai-generate-group" data-generate-group>
98+
<button class="ss-media-toolbar-btn ss-ai-generate-btn" data-action="generate">
99+
<span data-generate-label>Generate</span>
100+
</button>
101+
<span class="ss-ai-note" data-generate-note hidden>Generates on render</span>
102+
<button type="button" class="ss-ai-note ss-ai-note--action" data-generate-note-action hidden></button>
103+
</div>
99104
<span class="ss-ai-error" data-generate-error hidden></span>
100105
`;
101106

@@ -105,6 +110,8 @@ export class GenerateToolbar extends BaseToolbar {
105110
this.generateBtn = this.container.querySelector("[data-action='generate']");
106111
this.generateError = this.container.querySelector("[data-generate-error]");
107112
this.generateNote = this.container.querySelector("[data-generate-note]");
113+
this.generateNoteAction = this.container.querySelector("[data-generate-note-action]");
114+
this.generateGroup = this.container.querySelector("[data-generate-group]");
108115
this.modelBtn = this.container.querySelector("[data-model-picker]");
109116
this.modelLabel = this.container.querySelector("[data-model-label]");
110117
this.modelPopup = this.container.querySelector("[data-model-popup]");
@@ -163,6 +170,11 @@ export class GenerateToolbar extends BaseToolbar {
163170
e.stopPropagation();
164171
this.togglePopup(this.optionsPopup);
165172
}, { signal });
173+
this.generateNoteAction?.addEventListener("click", e => {
174+
e.stopPropagation();
175+
const clipId = this.getSelectedClipId();
176+
if (clipId) this.edit.getGenerationStatus(clipId)?.onActivate?.();
177+
}, { signal });
166178
}
167179

168180
private requestGeneration(): void {
@@ -417,22 +429,29 @@ export class GenerateToolbar extends BaseToolbar {
417429
const hasGenerator = this.edit.hasAssetGenerator();
418430
this.generateBtn.hidden = !hasGenerator;
419431
const status = hasGenerator ? this.edit.getGenerationStatus(this.getSelectedClipId() ?? "") : undefined;
420-
if (this.generateNote) {
432+
this.generateGroup?.classList.toggle("has-status", status !== undefined);
433+
if (this.generateNote && this.generateNoteAction) {
434+
const actionable = status?.onActivate !== undefined;
421435
if (!hasGenerator) {
422436
this.generateNote.textContent = "Generates on render";
423437
this.generateNote.title = "Rendering generates this from the prompt. Register an asset generator to preview it here.";
424438
delete this.generateNote.dataset["tone"];
425-
this.generateNote.hidden = false;
426439
} else if (status) {
427-
this.generateNote.textContent = status.text;
428-
this.generateNote.dataset["tone"] = status.tone ?? "neutral";
429-
this.generateNote.removeAttribute("title");
430-
this.generateNote.hidden = false;
431-
} else {
432-
this.generateNote.textContent = "";
433-
delete this.generateNote.dataset["tone"];
434-
this.generateNote.removeAttribute("title");
435-
this.generateNote.hidden = true;
440+
const target = actionable ? this.generateNoteAction : this.generateNote;
441+
target.textContent = status.text;
442+
target.dataset["tone"] = status.tone ?? "neutral";
443+
target.removeAttribute("title");
444+
}
445+
for (const [element, shown] of [
446+
[this.generateNote, !hasGenerator || (status !== undefined && !actionable)],
447+
[this.generateNoteAction, actionable]
448+
] as const) {
449+
if (!shown) {
450+
element.textContent = "";
451+
delete element.dataset["tone"];
452+
element.removeAttribute("title");
453+
}
454+
element.hidden = !shown;
436455
}
437456
}
438457
if (!hasGenerator) {
@@ -480,6 +499,8 @@ export class GenerateToolbar extends BaseToolbar {
480499
this.generateBtn = null;
481500
this.generateError = null;
482501
this.generateNote = null;
502+
this.generateNoteAction = null;
503+
this.generateGroup = null;
483504
this.modelBtn = null;
484505
this.modelLabel = null;
485506
this.modelPopup = null;

‎src/styles/ui/generate-toolbar.css‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,58 @@
8888
display: none;
8989
}
9090

91-
.ss-ai-note[data-tone="warning"] {
91+
.ss-ai-generate-group {
92+
display: inline-flex;
93+
align-items: center;
94+
border-radius: 6px;
95+
overflow: hidden;
96+
}
97+
98+
.ss-ai-generate-group.has-status > .ss-ai-generate-btn {
99+
border-radius: 0;
100+
}
101+
102+
.ss-ai-generate-group.has-status > .ss-ai-note:not([hidden]) {
103+
display: inline-flex;
104+
align-items: center;
105+
height: 32px;
106+
padding: 0 10px;
107+
background: rgba(255, 255, 255, 0.04);
108+
border-left: 1px solid rgba(255, 255, 255, 0.08);
109+
color: rgba(255, 255, 255, 0.7);
110+
font-size: 12px;
111+
font-weight: 500;
112+
font-variant-numeric: tabular-nums;
113+
}
114+
115+
.ss-ai-note--action {
116+
appearance: none;
117+
border: 0;
118+
font-family: inherit;
119+
cursor: pointer;
120+
}
121+
122+
.ss-ai-generate-group.has-status > .ss-ai-note--action:hover {
123+
background: rgba(255, 255, 255, 0.14);
124+
color: #fff;
125+
}
126+
127+
.ss-ai-generate-group.has-status > .ss-ai-note--action:active {
128+
background: rgba(255, 255, 255, 0.2);
129+
}
130+
131+
.ss-ai-note--action:focus-visible {
132+
outline: 2px solid rgba(255, 255, 255, 0.7);
133+
outline-offset: -3px;
134+
}
135+
136+
.ss-ai-note[data-tone="warning"],
137+
.ss-ai-generate-group.has-status > .ss-ai-note[data-tone="warning"]:not([hidden]) {
92138
color: #fcd34d;
93139
}
94140

95-
.ss-ai-note[data-tone="error"] {
141+
.ss-ai-note[data-tone="error"],
142+
.ss-ai-generate-group.has-status > .ss-ai-note[data-tone="error"]:not([hidden]) {
96143
color: #fca5a5;
97144
}
98145

‎tests/generate-toolbar.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,65 @@ describe("GenerateToolbar", () => {
583583
});
584584

585585
describe("host status", () => {
586+
it("opens the host action from a status button without generating", () => {
587+
const edit = createMockEdit();
588+
const onActivate = jest.fn();
589+
edit.getGenerationStatus.mockReturnValue({ text: "0.5 credits", onActivate });
590+
const { toolbar, container } = mountToolbar(edit);
591+
const action = container.querySelector<HTMLButtonElement>("[data-generate-note-action]");
592+
expect(action).not.toBeNull();
593+
expect(action?.hidden).toBe(false);
594+
expect(action?.type).toBe("button");
595+
expect(action?.textContent).toBe("0.5 credits");
596+
expect(container.querySelector<HTMLElement>("[data-generate-note]")?.hidden).toBe(true);
597+
action?.click();
598+
expect(onActivate).toHaveBeenCalledTimes(1);
599+
expect(edit.generateClip).not.toHaveBeenCalled();
600+
toolbar.dispose();
601+
});
602+
603+
it("uses the current action and clears it when status becomes passive or absent", () => {
604+
const edit = createMockEdit();
605+
const first = jest.fn();
606+
const second = jest.fn();
607+
edit.getGenerationStatus.mockReturnValue({ text: "First", onActivate: first });
608+
const { toolbar, container } = mountToolbar(edit);
609+
const action = container.querySelector<HTMLButtonElement>("[data-generate-note-action]");
610+
expect(action).not.toBeNull();
611+
edit.getGenerationStatus.mockReturnValue({ text: "Second", onActivate: second });
612+
toolbar.show(0, 0);
613+
action?.click();
614+
expect(second).toHaveBeenCalledTimes(1);
615+
expect(first).not.toHaveBeenCalled();
616+
edit.getGenerationStatus.mockReturnValue({ text: "Read only" });
617+
toolbar.show(0, 0);
618+
expect(action?.hidden).toBe(true);
619+
expect(action?.textContent).toBe("");
620+
expect(container.querySelector<HTMLElement>("[data-generate-note]")?.hidden).toBe(false);
621+
action?.click();
622+
expect(second).toHaveBeenCalledTimes(1);
623+
edit.getGenerationStatus.mockReturnValue(undefined);
624+
toolbar.show(0, 0);
625+
expect(container.querySelector<HTMLElement>("[data-generate-note]")?.hidden).toBe(true);
626+
expect(action?.hidden).toBe(true);
627+
toolbar.dispose();
628+
});
629+
630+
it("keeps the status action available when an error blocks generation", () => {
631+
const edit = createMockEdit();
632+
const onActivate = jest.fn();
633+
edit.getGenerationStatus.mockReturnValue({ text: "Add credits", tone: "error", onActivate });
634+
const { toolbar, container } = mountToolbar(edit);
635+
const action = container.querySelector<HTMLButtonElement>("[data-generate-note-action]");
636+
expect(action?.dataset["tone"]).toBe("error");
637+
expect(action?.disabled).toBe(false);
638+
expect(container.querySelector<HTMLButtonElement>("[data-action='generate']")?.disabled).toBe(true);
639+
action?.click();
640+
expect(onActivate).toHaveBeenCalledTimes(1);
641+
expect(edit.generateClip).not.toHaveBeenCalled();
642+
toolbar.dispose();
643+
});
644+
586645
it("keeps Generate and Enter blocked until an error status refresh settles", async () => {
587646
const edit = createMockEdit();
588647
let generations = 0;

0 commit comments

Comments
 (0)