pi-design-deck 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/deck-server.ts +12 -9
- package/form/css/controls.css +120 -0
- package/form/css/layout.css +56 -0
- package/form/css/preview.css +60 -6
- package/form/js/deck-core.js +14 -2
- package/form/js/deck-interact.js +35 -9
- package/form/js/deck-render.js +93 -6
- package/form/js/deck-session.js +122 -25
- package/generate-prompts.ts +16 -8
- package/index.ts +54 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
A tool for [Pi coding agent](https://github.com/badlogic/pi-mono/) that presents multi-slide visual decision decks in the browser. Each slide shows 2-4 high-fidelity previews — code diffs, architecture diagrams, UI mockups — and you pick one per slide. The agent gets back a clean selection map and moves on to implementation.
|
|
8
8
|
|
|
9
|
-
https://github.com/user-attachments/assets/
|
|
9
|
+
<img width="1340" alt="Design Deck screenshot" src="https://github.com/user-attachments/assets/20864ac6-9223-4e2e-ba3c-db3eaae0abd8" />
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
@@ -42,6 +42,8 @@ Restart pi to load the extension and the bundled `design-deck` skill.
|
|
|
42
42
|
**Requirements:**
|
|
43
43
|
- pi-agent v0.35.0 or later (extensions API)
|
|
44
44
|
|
|
45
|
+
https://github.com/user-attachments/assets/aff1bac6-8bc2-461a-8828-f588ce655f7f
|
|
46
|
+
|
|
45
47
|
## Quick Start
|
|
46
48
|
|
|
47
49
|
The agent builds slides as JSON. Each slide is one decision, each option is one approach:
|
|
@@ -178,7 +180,7 @@ The browser shows the new option with an entry animation. The tool blocks again,
|
|
|
178
180
|
|
|
179
181
|
### Model Override
|
|
180
182
|
|
|
181
|
-
The deck shows a model dropdown when 2+ models are available. Users pick which model generates new options. When a model other than the current one is selected, the generate-more result instructs the agent to
|
|
183
|
+
The deck shows a model dropdown when 2+ models are available. Users pick which model generates new options. When a model other than the current one is selected, the generate-more result instructs the agent to use the built-in `deck_generate` tool, which spawns pi headlessly with that model.
|
|
182
184
|
|
|
183
185
|
The default model can be set in the UI (saved to settings) or in `settings.json`:
|
|
184
186
|
|
|
@@ -286,6 +288,7 @@ Three modes of invocation:
|
|
|
286
288
|
pi-design-deck/
|
|
287
289
|
├── index.ts # Tool registration, module-level state, lifecycle
|
|
288
290
|
├── generate-prompts.ts # Prompt builders for generate-more / regenerate
|
|
291
|
+
├── model-runner.ts # Headless pi spawner for deck_generate tool
|
|
289
292
|
├── deck-schema.ts # TypeScript types and validation (no dependencies)
|
|
290
293
|
├── deck-server.ts # HTTP server, SSE, asset serving, snapshots
|
|
291
294
|
├── server-utils.ts # Shared HTTP/session utilities
|
package/deck-server.ts
CHANGED
|
@@ -173,16 +173,16 @@ export interface DeckServerOptions {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
export interface DeckServerCallbacks {
|
|
176
|
-
onSubmit: (selections: Record<string, string
|
|
176
|
+
onSubmit: (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => void;
|
|
177
177
|
onCancel: (reason?: "user" | "stale" | "aborted") => void;
|
|
178
|
-
onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
|
|
178
|
+
onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string, count?: number) => void;
|
|
179
179
|
onRegenerateSlide: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export interface DeckServerHandle {
|
|
183
183
|
url: string;
|
|
184
184
|
port: number;
|
|
185
|
-
close: () => void;
|
|
185
|
+
close: (reason?: string) => void;
|
|
186
186
|
pushOption: (slideId: string, option: DeckOption) => void;
|
|
187
187
|
cancelGenerate: () => void;
|
|
188
188
|
replaceSlideOptions: (slideId: string, options: DeckOption[]) => void;
|
|
@@ -414,12 +414,14 @@ export async function startDeckServer(
|
|
|
414
414
|
return;
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
const payload = body as { selections?: unknown };
|
|
417
|
+
const payload = body as { selections?: unknown; notes?: unknown; finalNotes?: unknown };
|
|
418
418
|
const selections = toStringMap(payload.selections);
|
|
419
419
|
if (!selections) {
|
|
420
420
|
sendJson(res, 400, { ok: false, error: "Invalid selections payload" });
|
|
421
421
|
return;
|
|
422
422
|
}
|
|
423
|
+
const notes = toStringMap(payload.notes) ?? undefined;
|
|
424
|
+
const finalNotes = typeof payload.finalNotes === "string" ? payload.finalNotes.trim() : undefined;
|
|
423
425
|
|
|
424
426
|
touchHeartbeat();
|
|
425
427
|
if (autoSaveOnSubmit !== false) {
|
|
@@ -431,7 +433,7 @@ export async function startDeckServer(
|
|
|
431
433
|
unregisterSession(sessionId);
|
|
432
434
|
pushEvent("deck-close", { reason: "submitted" });
|
|
433
435
|
sendJson(res, 200, { ok: true });
|
|
434
|
-
setImmediate(() => callbacks.onSubmit(selections));
|
|
436
|
+
setImmediate(() => callbacks.onSubmit(selections, notes, finalNotes || undefined));
|
|
435
437
|
return;
|
|
436
438
|
}
|
|
437
439
|
|
|
@@ -492,7 +494,7 @@ export async function startDeckServer(
|
|
|
492
494
|
return;
|
|
493
495
|
}
|
|
494
496
|
|
|
495
|
-
const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
|
|
497
|
+
const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string; count?: number };
|
|
496
498
|
if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
|
|
497
499
|
sendJson(res, 400, { ok: false, error: "slideId is required" });
|
|
498
500
|
return;
|
|
@@ -509,12 +511,13 @@ export async function startDeckServer(
|
|
|
509
511
|
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
|
|
510
512
|
const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
|
|
511
513
|
const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
|
|
514
|
+
const count = typeof payload.count === "number" && payload.count >= 1 && payload.count <= 5 ? payload.count : 1;
|
|
512
515
|
|
|
513
516
|
setPendingGenerate(payload.slideId as string, false);
|
|
514
517
|
touchHeartbeat();
|
|
515
518
|
sendJson(res, 200, { ok: true });
|
|
516
519
|
setImmediate(() => {
|
|
517
|
-
callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking);
|
|
520
|
+
callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking, count);
|
|
518
521
|
});
|
|
519
522
|
return;
|
|
520
523
|
}
|
|
@@ -603,11 +606,11 @@ export async function startDeckServer(
|
|
|
603
606
|
resolve({
|
|
604
607
|
url,
|
|
605
608
|
port: addr.port,
|
|
606
|
-
close: () => {
|
|
609
|
+
close: (reason?: string) => {
|
|
607
610
|
if (!completed) {
|
|
608
611
|
markCompleted();
|
|
609
612
|
unregisterSession(sessionId);
|
|
610
|
-
pushEvent("deck-close", { reason: "closed" });
|
|
613
|
+
pushEvent("deck-close", { reason: reason || "closed" });
|
|
611
614
|
}
|
|
612
615
|
try {
|
|
613
616
|
server.close();
|
package/form/css/controls.css
CHANGED
|
@@ -27,6 +27,30 @@
|
|
|
27
27
|
}
|
|
28
28
|
.btn-gen-more:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
29
29
|
|
|
30
|
+
.gen-group { display: flex; align-items: center; gap: 6px; }
|
|
31
|
+
|
|
32
|
+
.gen-count {
|
|
33
|
+
appearance: none;
|
|
34
|
+
-webkit-appearance: none;
|
|
35
|
+
background: rgba(var(--dk-ink),0.12);
|
|
36
|
+
border: 1px solid rgba(var(--dk-ink),0.15);
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
padding: 4px 8px;
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
color: var(--dk-accent-text);
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
text-align: center;
|
|
44
|
+
min-width: 32px;
|
|
45
|
+
}
|
|
46
|
+
.gen-count:focus { outline: none; border-color: rgba(138,190,183,0.4); background: rgba(138,190,183,0.1); }
|
|
47
|
+
.gen-count:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
48
|
+
|
|
49
|
+
.gen-count-label {
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
color: var(--dk-text-dim);
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
.btn-regen {
|
|
31
55
|
display: inline-flex; align-items: center; gap: 6px;
|
|
32
56
|
padding: 8px 14px; border-radius: 8px;
|
|
@@ -120,6 +144,102 @@
|
|
|
120
144
|
}
|
|
121
145
|
@keyframes skel-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
|
|
122
146
|
|
|
147
|
+
/* Regeneration overlay - covers options grid during regenerate-all */
|
|
148
|
+
.regen-overlay {
|
|
149
|
+
position: absolute;
|
|
150
|
+
inset: 0;
|
|
151
|
+
display: grid;
|
|
152
|
+
gap: 20px;
|
|
153
|
+
padding: 0;
|
|
154
|
+
z-index: 10;
|
|
155
|
+
animation: regen-fade-in 0.3s ease-out;
|
|
156
|
+
}
|
|
157
|
+
.regen-overlay.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
|
158
|
+
.regen-overlay.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
|
159
|
+
.regen-overlay.cols-1 { grid-template-columns: 1fr; }
|
|
160
|
+
|
|
161
|
+
@keyframes regen-fade-in {
|
|
162
|
+
from { opacity: 0; }
|
|
163
|
+
to { opacity: 1; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.regen-skeleton {
|
|
167
|
+
border: 2px dashed rgba(138,190,183,0.15);
|
|
168
|
+
border-radius: 12px;
|
|
169
|
+
background: var(--dk-surface);
|
|
170
|
+
min-height: 280px;
|
|
171
|
+
position: relative;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
}
|
|
174
|
+
.regen-skeleton::before {
|
|
175
|
+
content: '';
|
|
176
|
+
position: absolute;
|
|
177
|
+
top: 0; left: 0; right: 0;
|
|
178
|
+
height: 44px;
|
|
179
|
+
background: linear-gradient(110deg, rgba(var(--dk-ink),0.03) 30%, rgba(var(--dk-ink),0.07) 50%, rgba(var(--dk-ink),0.03) 70%);
|
|
180
|
+
background-size: 200% 100%;
|
|
181
|
+
animation: skel-shimmer 1.5s ease-in-out infinite;
|
|
182
|
+
border-bottom: 1px solid rgba(var(--dk-ink),0.06);
|
|
183
|
+
}
|
|
184
|
+
.regen-skeleton::after {
|
|
185
|
+
content: '';
|
|
186
|
+
position: absolute;
|
|
187
|
+
top: 45px; left: 0; right: 0; bottom: 0;
|
|
188
|
+
background: linear-gradient(110deg, var(--dk-surface) 30%, var(--dk-surface-shimmer) 50%, var(--dk-surface) 70%);
|
|
189
|
+
background-size: 200% 100%;
|
|
190
|
+
animation: skel-shimmer 1.5s ease-in-out infinite;
|
|
191
|
+
animation-delay: 0.1s;
|
|
192
|
+
}
|
|
193
|
+
.regen-skeleton:nth-child(2)::before,
|
|
194
|
+
.regen-skeleton:nth-child(2)::after { animation-delay: 0.2s; }
|
|
195
|
+
.regen-skeleton:nth-child(3)::before,
|
|
196
|
+
.regen-skeleton:nth-child(3)::after { animation-delay: 0.3s; }
|
|
197
|
+
.regen-skeleton:nth-child(4)::before,
|
|
198
|
+
.regen-skeleton:nth-child(4)::after { animation-delay: 0.4s; }
|
|
199
|
+
|
|
200
|
+
/* Regeneration status text */
|
|
201
|
+
.regen-status {
|
|
202
|
+
position: absolute;
|
|
203
|
+
bottom: 16px; left: 50%;
|
|
204
|
+
transform: translateX(-50%);
|
|
205
|
+
font-family: var(--dk-font-mono);
|
|
206
|
+
font-size: 11px;
|
|
207
|
+
color: var(--dk-accent-text);
|
|
208
|
+
letter-spacing: 0.3px;
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
gap: 8px;
|
|
212
|
+
}
|
|
213
|
+
.regen-status::before {
|
|
214
|
+
content: '';
|
|
215
|
+
width: 14px; height: 14px;
|
|
216
|
+
border: 2px solid rgba(138,190,183,0.2);
|
|
217
|
+
border-top-color: var(--dk-accent);
|
|
218
|
+
border-radius: 50%;
|
|
219
|
+
animation: spin 0.8s linear infinite;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Options grid regenerating state */
|
|
223
|
+
.options.regenerating {
|
|
224
|
+
opacity: 0;
|
|
225
|
+
pointer-events: none;
|
|
226
|
+
transition: opacity 0.25s ease-out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Regenerated options entry animation */
|
|
230
|
+
.option-regenerated {
|
|
231
|
+
animation: opt-regen-appear 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
232
|
+
opacity: 0;
|
|
233
|
+
}
|
|
234
|
+
.option-regenerated:nth-child(1) { animation-delay: 0.05s; }
|
|
235
|
+
.option-regenerated:nth-child(2) { animation-delay: 0.1s; }
|
|
236
|
+
.option-regenerated:nth-child(3) { animation-delay: 0.15s; }
|
|
237
|
+
.option-regenerated:nth-child(4) { animation-delay: 0.2s; }
|
|
238
|
+
@keyframes opt-regen-appear {
|
|
239
|
+
0% { opacity: 0; transform: translateY(12px) scale(0.96); }
|
|
240
|
+
100% { opacity: 1; transform: translateY(0) scale(1); }
|
|
241
|
+
}
|
|
242
|
+
|
|
123
243
|
/* ─────────────────────────────────────────────────────────────
|
|
124
244
|
MODEL BAR
|
|
125
245
|
───────────────────────────────────────────────────────────── */
|
package/form/css/layout.css
CHANGED
|
@@ -268,6 +268,21 @@ body {
|
|
|
268
268
|
color: var(--dk-text-secondary); font-family: var(--dk-font-mono);
|
|
269
269
|
font-style: italic;
|
|
270
270
|
}
|
|
271
|
+
.summary-notes {
|
|
272
|
+
margin-top: 10px; padding: 8px 10px;
|
|
273
|
+
font-size: 11px; line-height: 1.5;
|
|
274
|
+
color: var(--dk-accent-text);
|
|
275
|
+
background: rgba(138,190,183,0.08);
|
|
276
|
+
border-radius: 4px;
|
|
277
|
+
border-left: 2px solid rgba(138,190,183,0.3);
|
|
278
|
+
}
|
|
279
|
+
.summary-notes-label {
|
|
280
|
+
font-weight: 600;
|
|
281
|
+
font-size: 9px;
|
|
282
|
+
text-transform: uppercase;
|
|
283
|
+
letter-spacing: 0.3px;
|
|
284
|
+
opacity: 0.8;
|
|
285
|
+
}
|
|
271
286
|
.summary-preview {
|
|
272
287
|
margin-top: 12px; border-radius: 6px; overflow: hidden;
|
|
273
288
|
border: 1px solid rgba(var(--dk-ink),0.12); max-height: 120px;
|
|
@@ -288,6 +303,47 @@ body {
|
|
|
288
303
|
}
|
|
289
304
|
.summary-mermaid svg { max-width: 100%; max-height: 100%; }
|
|
290
305
|
|
|
306
|
+
/* Final notes input on summary slide */
|
|
307
|
+
.final-notes {
|
|
308
|
+
width: 100%;
|
|
309
|
+
max-width: 560px;
|
|
310
|
+
margin-top: 24px;
|
|
311
|
+
display: flex;
|
|
312
|
+
flex-direction: column;
|
|
313
|
+
gap: 8px;
|
|
314
|
+
}
|
|
315
|
+
.final-notes-label {
|
|
316
|
+
font-size: 11px;
|
|
317
|
+
font-weight: 500;
|
|
318
|
+
color: var(--dk-text-dim);
|
|
319
|
+
text-transform: uppercase;
|
|
320
|
+
letter-spacing: 0.5px;
|
|
321
|
+
}
|
|
322
|
+
.final-notes-input {
|
|
323
|
+
width: 100%;
|
|
324
|
+
padding: 12px 14px;
|
|
325
|
+
border-radius: 8px;
|
|
326
|
+
border: 1px solid rgba(var(--dk-ink),0.12);
|
|
327
|
+
background: rgba(var(--dk-ink),0.04);
|
|
328
|
+
font-size: 13px;
|
|
329
|
+
font-family: inherit;
|
|
330
|
+
color: var(--dk-text);
|
|
331
|
+
resize: vertical;
|
|
332
|
+
min-height: 60px;
|
|
333
|
+
max-height: 120px;
|
|
334
|
+
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
|
335
|
+
}
|
|
336
|
+
.final-notes-input::placeholder {
|
|
337
|
+
color: var(--dk-text-placeholder);
|
|
338
|
+
font-style: italic;
|
|
339
|
+
}
|
|
340
|
+
.final-notes-input:focus {
|
|
341
|
+
outline: none;
|
|
342
|
+
border-color: rgba(138,190,183,0.4);
|
|
343
|
+
background: rgba(var(--dk-ink),0.06);
|
|
344
|
+
box-shadow: 0 0 0 3px rgba(138,190,183,0.1);
|
|
345
|
+
}
|
|
346
|
+
|
|
291
347
|
.btn-generate {
|
|
292
348
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
293
349
|
padding: 14px 48px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
package/form/css/preview.css
CHANGED
|
@@ -59,17 +59,71 @@ code[class*="language-"], pre[class*="language-"] {
|
|
|
59
59
|
OPTION ASIDE
|
|
60
60
|
───────────────────────────────────────────────────────────── */
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
/* Option footer - contains aside + notes */
|
|
63
|
+
.option-footer {
|
|
64
64
|
padding: 12px 14px;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.option-aside {
|
|
71
|
+
padding: 8px 10px;
|
|
72
|
+
border-left: 2px solid rgba(138,190,183,0.2);
|
|
73
|
+
background: rgba(var(--dk-ink),0.02);
|
|
74
|
+
font-size: 11px; line-height: 1.55;
|
|
69
75
|
color: var(--dk-text-aside);
|
|
70
76
|
font-family: var(--dk-font-mono);
|
|
71
77
|
font-style: italic;
|
|
72
78
|
letter-spacing: -0.01em;
|
|
79
|
+
border-radius: 0 4px 4px 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Option notes input */
|
|
83
|
+
.option-notes {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 8px;
|
|
87
|
+
}
|
|
88
|
+
.option-notes-label {
|
|
89
|
+
font-size: 9px;
|
|
90
|
+
font-weight: 500;
|
|
91
|
+
color: var(--dk-text-hint);
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
letter-spacing: 0.3px;
|
|
94
|
+
white-space: nowrap;
|
|
95
|
+
opacity: 0.6;
|
|
96
|
+
}
|
|
97
|
+
.option-notes-input {
|
|
98
|
+
flex: 1;
|
|
99
|
+
padding: 5px 8px;
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
border: 1px dashed rgba(var(--dk-ink),0.12);
|
|
102
|
+
background: transparent;
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
font-family: var(--dk-font-mono);
|
|
105
|
+
color: var(--dk-text);
|
|
106
|
+
resize: none;
|
|
107
|
+
min-height: 24px;
|
|
108
|
+
transition: border-color 0.2s, background 0.2s;
|
|
109
|
+
}
|
|
110
|
+
.option-notes-input::placeholder {
|
|
111
|
+
color: var(--dk-text-placeholder);
|
|
112
|
+
opacity: 0.4;
|
|
113
|
+
font-size: 10px;
|
|
114
|
+
}
|
|
115
|
+
.option-notes-input:focus {
|
|
116
|
+
outline: none;
|
|
117
|
+
border-style: solid;
|
|
118
|
+
border-color: rgba(138,190,183,0.3);
|
|
119
|
+
background: rgba(var(--dk-ink),0.02);
|
|
120
|
+
}
|
|
121
|
+
.option.selected .option-notes-input {
|
|
122
|
+
border-color: rgba(138,190,183,0.2);
|
|
123
|
+
}
|
|
124
|
+
.option-notes-input:disabled {
|
|
125
|
+
opacity: 0.4;
|
|
126
|
+
cursor: not-allowed;
|
|
73
127
|
}
|
|
74
128
|
|
|
75
129
|
/* ─────────────────────────────────────────────────────────────
|
package/form/js/deck-core.js
CHANGED
|
@@ -23,6 +23,8 @@ let isClosed = false;
|
|
|
23
23
|
let isSubmitting = false;
|
|
24
24
|
|
|
25
25
|
const selections = {};
|
|
26
|
+
const optionNotes = {};
|
|
27
|
+
let finalNotes = "";
|
|
26
28
|
const pendingGenerate = new Map();
|
|
27
29
|
let selectedModel = "";
|
|
28
30
|
let selectedThinking = "off";
|
|
@@ -184,13 +186,23 @@ function initTheme() {
|
|
|
184
186
|
const SELECTIONS_KEY = `pi-deck-${typeof deckData.sessionId === "string" ? deckData.sessionId : "unknown"}`;
|
|
185
187
|
|
|
186
188
|
function saveSelectionsToStorage() {
|
|
187
|
-
try {
|
|
189
|
+
try {
|
|
190
|
+
const data = { selections, optionNotes };
|
|
191
|
+
if (finalNotes) data.finalNotes = finalNotes;
|
|
192
|
+
localStorage.setItem(SELECTIONS_KEY, JSON.stringify(data));
|
|
193
|
+
} catch {}
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
function loadSelectionsFromStorage() {
|
|
191
197
|
try {
|
|
192
198
|
const saved = localStorage.getItem(SELECTIONS_KEY);
|
|
193
|
-
|
|
199
|
+
if (!saved) return null;
|
|
200
|
+
const parsed = JSON.parse(saved);
|
|
201
|
+
// Handle both old format (just selections) and new format ({ selections, optionNotes })
|
|
202
|
+
if (parsed && typeof parsed === "object" && !parsed.selections) {
|
|
203
|
+
return { selections: parsed, optionNotes: {} };
|
|
204
|
+
}
|
|
205
|
+
return parsed;
|
|
194
206
|
} catch { return null; }
|
|
195
207
|
}
|
|
196
208
|
|
package/form/js/deck-interact.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// ─── SELECTION ───────────────────────────────────────────────
|
|
2
2
|
|
|
3
|
-
function applySavedSelections(
|
|
4
|
-
if (!
|
|
5
|
-
for (const [slideId, label] of Object.entries(
|
|
3
|
+
function applySavedSelections(savedSelections, savedNotes) {
|
|
4
|
+
if (!savedSelections || typeof savedSelections !== "object") return;
|
|
5
|
+
for (const [slideId, label] of Object.entries(savedSelections)) {
|
|
6
6
|
if (typeof label !== "string") continue;
|
|
7
7
|
const slideEl = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
|
|
8
8
|
if (!slideEl) continue;
|
|
@@ -13,16 +13,35 @@ function applySavedSelections(saved) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
// Restore notes
|
|
17
|
+
if (savedNotes && typeof savedNotes === "object") {
|
|
18
|
+
for (const [slideId, noteData] of Object.entries(savedNotes)) {
|
|
19
|
+
if (noteData && typeof noteData === "object" && noteData.label && noteData.notes) {
|
|
20
|
+
optionNotes[slideId] = noteData;
|
|
21
|
+
// Find and populate the textarea
|
|
22
|
+
const input = document.querySelector(`.option-notes-input[data-slide-id="${CSS.escape(slideId)}"][data-option-label="${CSS.escape(noteData.label)}"]`);
|
|
23
|
+
if (input) input.value = noteData.notes;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
function restoreSelections() {
|
|
19
30
|
const serverSaved = deckData.savedSelections;
|
|
20
31
|
if (serverSaved && typeof serverSaved === "object" && Object.keys(serverSaved).length > 0) {
|
|
21
|
-
applySavedSelections(serverSaved);
|
|
32
|
+
applySavedSelections(serverSaved, null);
|
|
22
33
|
return;
|
|
23
34
|
}
|
|
24
35
|
const stored = loadSelectionsFromStorage();
|
|
25
|
-
if (stored)
|
|
36
|
+
if (stored) {
|
|
37
|
+
applySavedSelections(stored.selections || stored, stored.optionNotes);
|
|
38
|
+
// Restore final notes if present
|
|
39
|
+
if (stored.finalNotes) {
|
|
40
|
+
finalNotes = stored.finalNotes;
|
|
41
|
+
const input = document.getElementById("final-notes-input");
|
|
42
|
+
if (input) input.value = stored.finalNotes;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
function applySelectionClasses(slideElement, selectedElement) {
|
|
@@ -337,7 +356,8 @@ function initModelBar(modelsData) {
|
|
|
337
356
|
}
|
|
338
357
|
|
|
339
358
|
function syncDefaultCheck() {
|
|
340
|
-
|
|
359
|
+
const effectiveModel = selectedModel || modelsData.current;
|
|
360
|
+
defaultCheck.checked = modelsData.defaultModel != null && effectiveModel === modelsData.defaultModel;
|
|
341
361
|
}
|
|
342
362
|
|
|
343
363
|
function activateProvider(provider) {
|
|
@@ -369,12 +389,18 @@ function initModelBar(modelsData) {
|
|
|
369
389
|
});
|
|
370
390
|
|
|
371
391
|
defaultCheck.addEventListener("change", async () => {
|
|
372
|
-
|
|
373
|
-
const
|
|
392
|
+
// Use selectedModel, or fall back to current model when on "Current" pill
|
|
393
|
+
const modelToSave = selectedModel || modelsData.current;
|
|
394
|
+
if (defaultCheck.checked && !modelToSave) { defaultCheck.checked = false; return; }
|
|
395
|
+
const model = defaultCheck.checked ? modelToSave : null;
|
|
374
396
|
try {
|
|
375
397
|
await postJson("/save-model-default", { token: sessionToken, model });
|
|
376
398
|
modelsData.defaultModel = model;
|
|
377
|
-
} catch {
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.error("Failed to save default model:", err);
|
|
401
|
+
// Revert checkbox to match actual state
|
|
402
|
+
syncDefaultCheck();
|
|
403
|
+
}
|
|
378
404
|
});
|
|
379
405
|
|
|
380
406
|
if (modelsData.defaultModel) {
|
package/form/js/deck-render.js
CHANGED
|
@@ -114,6 +114,10 @@ function applyPreviewHtml(preview, previewHtml) {
|
|
|
114
114
|
if (dataset.fonts) {
|
|
115
115
|
preview.dataset.fonts = dataset.fonts;
|
|
116
116
|
}
|
|
117
|
+
// Copy inline styles from source preview div
|
|
118
|
+
if (first.style.cssText) {
|
|
119
|
+
preview.style.cssText = first.style.cssText;
|
|
120
|
+
}
|
|
117
121
|
preview.innerHTML = first.innerHTML;
|
|
118
122
|
return;
|
|
119
123
|
}
|
|
@@ -186,11 +190,47 @@ function createOptionCard(option, slideId, generatedBy) {
|
|
|
186
190
|
el.tabIndex = -1;
|
|
187
191
|
});
|
|
188
192
|
|
|
193
|
+
// Footer contains aside (optional) + notes input
|
|
194
|
+
const footer = createElement("div", "option-footer");
|
|
195
|
+
|
|
189
196
|
if (option.aside) {
|
|
190
197
|
const aside = createElement("div", "option-aside");
|
|
191
|
-
|
|
192
|
-
|
|
198
|
+
// Handle both actual newlines and literal \n sequences
|
|
199
|
+
aside.innerHTML = escapeHtml(option.aside).replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
|
|
200
|
+
footer.appendChild(aside);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Notes input for user instructions
|
|
204
|
+
const notesContainer = createElement("div", "option-notes");
|
|
205
|
+
const notesLabel = createElement("label", "option-notes-label", "Your notes (optional)");
|
|
206
|
+
const notesInput = createElement("textarea", "option-notes-input");
|
|
207
|
+
notesInput.placeholder = "Add notes...";
|
|
208
|
+
notesInput.rows = 1;
|
|
209
|
+
notesInput.dataset.slideId = slideId;
|
|
210
|
+
notesInput.dataset.optionLabel = option.label;
|
|
211
|
+
|
|
212
|
+
// Restore saved notes if this option was previously selected with notes
|
|
213
|
+
if (optionNotes[slideId]?.label === option.label && optionNotes[slideId]?.notes) {
|
|
214
|
+
notesInput.value = optionNotes[slideId].notes;
|
|
193
215
|
}
|
|
216
|
+
|
|
217
|
+
notesInput.addEventListener("input", (e) => {
|
|
218
|
+
const value = e.target.value.trim();
|
|
219
|
+
if (value) {
|
|
220
|
+
optionNotes[slideId] = { label: option.label, notes: value };
|
|
221
|
+
} else if (optionNotes[slideId]?.label === option.label) {
|
|
222
|
+
delete optionNotes[slideId];
|
|
223
|
+
}
|
|
224
|
+
saveSelectionsToStorage();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Prevent click from bubbling to card (which would select it)
|
|
228
|
+
notesInput.addEventListener("click", (e) => e.stopPropagation());
|
|
229
|
+
|
|
230
|
+
notesContainer.appendChild(notesLabel);
|
|
231
|
+
notesContainer.appendChild(notesInput);
|
|
232
|
+
footer.appendChild(notesContainer);
|
|
233
|
+
card.appendChild(footer);
|
|
194
234
|
|
|
195
235
|
if (option.description) {
|
|
196
236
|
card.setAttribute("title", option.description);
|
|
@@ -207,10 +247,33 @@ function createGenerateBar(slideId) {
|
|
|
207
247
|
const bar = createElement("div", "gen-bar");
|
|
208
248
|
|
|
209
249
|
const row = createElement("div", "gen-row");
|
|
250
|
+
|
|
251
|
+
// Generate button with separate count selector
|
|
252
|
+
const genGroup = createElement("div", "gen-group");
|
|
210
253
|
const button = createElement("button", "btn-gen-more");
|
|
211
254
|
button.type = "button";
|
|
212
255
|
button.appendChild(createElement("span", "btn-gen-plus", "+"));
|
|
213
|
-
button.appendChild(document.createTextNode("
|
|
256
|
+
button.appendChild(document.createTextNode("Generate"));
|
|
257
|
+
|
|
258
|
+
const countSelect = document.createElement("select");
|
|
259
|
+
countSelect.className = "gen-count";
|
|
260
|
+
countSelect.setAttribute("aria-label", "Number of options to generate");
|
|
261
|
+
[1, 2, 3].forEach((n) => {
|
|
262
|
+
const opt = document.createElement("option");
|
|
263
|
+
opt.value = n;
|
|
264
|
+
opt.textContent = n;
|
|
265
|
+
countSelect.appendChild(opt);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const countLabel = createElement("span", "gen-count-label", "option");
|
|
269
|
+
countSelect.addEventListener("change", () => {
|
|
270
|
+
const count = parseInt(countSelect.value, 10);
|
|
271
|
+
countLabel.textContent = count === 1 ? "option" : "options";
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
genGroup.appendChild(button);
|
|
275
|
+
genGroup.appendChild(countSelect);
|
|
276
|
+
genGroup.appendChild(countLabel);
|
|
214
277
|
|
|
215
278
|
const regenButton = createElement("button", "btn-regen");
|
|
216
279
|
regenButton.type = "button";
|
|
@@ -231,8 +294,8 @@ function createGenerateBar(slideId) {
|
|
|
231
294
|
e.stopPropagation();
|
|
232
295
|
});
|
|
233
296
|
|
|
234
|
-
button.addEventListener("click", () => generateMore(button, slideId, input));
|
|
235
|
-
row.appendChild(
|
|
297
|
+
button.addEventListener("click", () => generateMore(button, slideId, input, countSelect));
|
|
298
|
+
row.appendChild(genGroup);
|
|
236
299
|
row.appendChild(regenButton);
|
|
237
300
|
row.appendChild(input);
|
|
238
301
|
bar.appendChild(row);
|
|
@@ -301,6 +364,22 @@ function createSummarySlide(index) {
|
|
|
301
364
|
summaryGrid.id = "summary-grid";
|
|
302
365
|
section.appendChild(summaryGrid);
|
|
303
366
|
|
|
367
|
+
// Final instructions textarea
|
|
368
|
+
const finalNotesContainer = createElement("div", "final-notes");
|
|
369
|
+
const finalNotesLabel = createElement("label", "final-notes-label", "Additional instructions (optional)");
|
|
370
|
+
finalNotesLabel.setAttribute("for", "final-notes-input");
|
|
371
|
+
const finalNotesInput = createElement("textarea", "final-notes-input");
|
|
372
|
+
finalNotesInput.id = "final-notes-input";
|
|
373
|
+
finalNotesInput.placeholder = "Add any final notes or instructions for implementation...";
|
|
374
|
+
finalNotesInput.rows = 2;
|
|
375
|
+
finalNotesInput.addEventListener("input", (e) => {
|
|
376
|
+
finalNotes = e.target.value.trim();
|
|
377
|
+
saveSelectionsToStorage();
|
|
378
|
+
});
|
|
379
|
+
finalNotesContainer.appendChild(finalNotesLabel);
|
|
380
|
+
finalNotesContainer.appendChild(finalNotesInput);
|
|
381
|
+
section.appendChild(finalNotesContainer);
|
|
382
|
+
|
|
304
383
|
const submitButton = createElement("button", "btn-generate", "Submit Selections");
|
|
305
384
|
submitButton.type = "button";
|
|
306
385
|
submitButton.id = "btn-generate";
|
|
@@ -382,10 +461,18 @@ function createSummaryCard(slide) {
|
|
|
382
461
|
const text = selectedOption.aside.length > 120
|
|
383
462
|
? selectedOption.aside.slice(0, 120).trimEnd() + "..."
|
|
384
463
|
: selectedOption.aside;
|
|
385
|
-
aside.
|
|
464
|
+
aside.innerHTML = escapeHtml(text).replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
|
|
386
465
|
card.appendChild(aside);
|
|
387
466
|
}
|
|
388
467
|
}
|
|
468
|
+
|
|
469
|
+
// Show user notes if present
|
|
470
|
+
const noteData = optionNotes[slide.id];
|
|
471
|
+
if (noteData && noteData.label === selectedLabel && noteData.notes) {
|
|
472
|
+
const notesDisplay = createElement("div", "summary-notes");
|
|
473
|
+
notesDisplay.innerHTML = `<span class="summary-notes-label">Your notes:</span> ${escapeHtml(noteData.notes)}`;
|
|
474
|
+
card.appendChild(notesDisplay);
|
|
475
|
+
}
|
|
389
476
|
}
|
|
390
477
|
|
|
391
478
|
return card;
|
package/form/js/deck-session.js
CHANGED
|
@@ -74,7 +74,7 @@ function disableDeckInteractions() {
|
|
|
74
74
|
document.querySelectorAll(".btn-gen-more, .btn-regen").forEach((button) => {
|
|
75
75
|
button.disabled = true;
|
|
76
76
|
});
|
|
77
|
-
document.querySelectorAll(".gen-prompt").forEach((input) => {
|
|
77
|
+
document.querySelectorAll(".gen-prompt, .gen-count").forEach((input) => {
|
|
78
78
|
input.disabled = true;
|
|
79
79
|
});
|
|
80
80
|
document.querySelectorAll(".model-pill, .model-list-item, .model-default-check").forEach((el) => {
|
|
@@ -98,7 +98,16 @@ async function submitDeck() {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
try {
|
|
101
|
-
|
|
101
|
+
// Build notes object with only notes for selected options
|
|
102
|
+
const notes = {};
|
|
103
|
+
for (const [slideId, noteData] of Object.entries(optionNotes)) {
|
|
104
|
+
if (noteData && selections[slideId] === noteData.label && noteData.notes) {
|
|
105
|
+
notes[slideId] = noteData.notes;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const payload = { token: sessionToken, selections, notes };
|
|
109
|
+
if (finalNotes) payload.finalNotes = finalNotes;
|
|
110
|
+
await postJson("/submit", payload);
|
|
102
111
|
clearSelectionsStorage();
|
|
103
112
|
isClosed = true;
|
|
104
113
|
if (submitButton) {
|
|
@@ -242,16 +251,22 @@ function restoreGenerateButton(slideId) {
|
|
|
242
251
|
const pending = pendingGenerate.get(slideId);
|
|
243
252
|
if (!pending || pending.isRegen) return;
|
|
244
253
|
|
|
245
|
-
|
|
246
|
-
|
|
254
|
+
// Clear timeout if any
|
|
255
|
+
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
256
|
+
|
|
257
|
+
// Remove all skeletons from DOM (query directly, don't rely on array)
|
|
258
|
+
const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
|
|
259
|
+
if (slideElement) {
|
|
260
|
+
slideElement.querySelectorAll(".option-skeleton").forEach((skel) => skel.remove());
|
|
247
261
|
}
|
|
262
|
+
|
|
248
263
|
pending.button.classList.remove("loading");
|
|
249
264
|
const plus = pending.button.querySelector(".btn-gen-plus");
|
|
250
265
|
if (plus) plus.textContent = "+";
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
266
|
+
pending.button.lastChild.textContent = "Generate";
|
|
267
|
+
|
|
254
268
|
if (pending.input && !isClosed) pending.input.disabled = false;
|
|
269
|
+
if (pending.countSelect && !isClosed) pending.countSelect.disabled = false;
|
|
255
270
|
|
|
256
271
|
pendingGenerate.delete(slideId);
|
|
257
272
|
}
|
|
@@ -301,6 +316,7 @@ function replaceSlideOptions(slideId, newOptions) {
|
|
|
301
316
|
slide.options = newOptions;
|
|
302
317
|
|
|
303
318
|
delete selections[slideId];
|
|
319
|
+
delete optionNotes[slideId];
|
|
304
320
|
saveSelectionsToStorage();
|
|
305
321
|
|
|
306
322
|
const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
|
|
@@ -309,16 +325,28 @@ function replaceSlideOptions(slideId, newOptions) {
|
|
|
309
325
|
const optionsGrid = slideElement.querySelector(".options");
|
|
310
326
|
if (!optionsGrid) return;
|
|
311
327
|
|
|
328
|
+
// Remove regeneration overlay and state
|
|
329
|
+
const overlay = optionsGrid.querySelector(".regen-overlay");
|
|
330
|
+
if (overlay) overlay.remove();
|
|
331
|
+
optionsGrid.classList.remove("regenerating");
|
|
332
|
+
optionsGrid.style.position = "";
|
|
333
|
+
|
|
312
334
|
optionsGrid.innerHTML = "";
|
|
313
|
-
optionsGrid.style.opacity = "";
|
|
314
|
-
optionsGrid.style.pointerEvents = "";
|
|
315
335
|
optionsGrid.className = `options ${optionCountClass(newOptions.length, slide.columns)}`;
|
|
316
336
|
|
|
317
337
|
newOptions.forEach((option) => {
|
|
318
338
|
const card = createOptionCard(option, slideId, false);
|
|
339
|
+
card.classList.add("option-regenerated");
|
|
319
340
|
optionsGrid.appendChild(card);
|
|
320
341
|
});
|
|
321
342
|
|
|
343
|
+
// Remove animation class after animation completes
|
|
344
|
+
setTimeout(() => {
|
|
345
|
+
optionsGrid.querySelectorAll(".option-regenerated").forEach((el) => {
|
|
346
|
+
el.classList.remove("option-regenerated");
|
|
347
|
+
});
|
|
348
|
+
}, 500);
|
|
349
|
+
|
|
322
350
|
equalizeBlockHeights(slideElement);
|
|
323
351
|
|
|
324
352
|
const pick = slideElement.querySelector(".slide-pick");
|
|
@@ -343,9 +371,37 @@ function connectEvents() {
|
|
|
343
371
|
if (!payload || typeof payload.slideId !== "string" || !payload.option) {
|
|
344
372
|
return;
|
|
345
373
|
}
|
|
346
|
-
const
|
|
347
|
-
|
|
374
|
+
const pending = pendingGenerate.get(payload.slideId);
|
|
375
|
+
const model = pending?.model || null;
|
|
376
|
+
|
|
377
|
+
// Remove one skeleton
|
|
378
|
+
if (pending?.skeletons?.length > 0) {
|
|
379
|
+
const skel = pending.skeletons.shift();
|
|
380
|
+
if (skel.parentElement) skel.remove();
|
|
381
|
+
}
|
|
382
|
+
|
|
348
383
|
insertGeneratedOption(payload.slideId, payload.option, model);
|
|
384
|
+
|
|
385
|
+
// Track received count and restore button when all received
|
|
386
|
+
if (pending) {
|
|
387
|
+
pending.receivedCount = (pending.receivedCount || 0) + 1;
|
|
388
|
+
if (pending.receivedCount >= (pending.expectedCount || 1)) {
|
|
389
|
+
restoreGenerateButton(payload.slideId);
|
|
390
|
+
} else {
|
|
391
|
+
// Reset timeout for next option
|
|
392
|
+
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
393
|
+
pending.timeoutId = setTimeout(() => {
|
|
394
|
+
const current = pendingGenerate.get(payload.slideId);
|
|
395
|
+
if (!current || current.isRegen) return;
|
|
396
|
+
const received = current.receivedCount || 0;
|
|
397
|
+
const expected = current.expectedCount || 1;
|
|
398
|
+
restoreGenerateButton(payload.slideId);
|
|
399
|
+
if (received < expected) {
|
|
400
|
+
showSaveToast(`Generated ${received} of ${expected} options`, true);
|
|
401
|
+
}
|
|
402
|
+
}, 30000);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
349
405
|
});
|
|
350
406
|
|
|
351
407
|
events.addEventListener("generate-failed", (event) => {
|
|
@@ -359,6 +415,8 @@ function connectEvents() {
|
|
|
359
415
|
restoreGenerateButton(payload.slideId);
|
|
360
416
|
if (payload.reason === "timeout") {
|
|
361
417
|
showSaveToast("Generation timed out — try again", true);
|
|
418
|
+
} else {
|
|
419
|
+
showSaveToast("Generation failed", true);
|
|
362
420
|
}
|
|
363
421
|
}
|
|
364
422
|
});
|
|
@@ -388,6 +446,8 @@ function connectEvents() {
|
|
|
388
446
|
restoreRegenButton(payload.slideId);
|
|
389
447
|
if (payload.reason === "timeout") {
|
|
390
448
|
showSaveToast("Regeneration timed out — try again", true);
|
|
449
|
+
} else {
|
|
450
|
+
showSaveToast("Regeneration failed", true);
|
|
391
451
|
}
|
|
392
452
|
}
|
|
393
453
|
});
|
|
@@ -415,7 +475,7 @@ function connectEvents() {
|
|
|
415
475
|
});
|
|
416
476
|
}
|
|
417
477
|
|
|
418
|
-
async function generateMore(button, slideId, input) {
|
|
478
|
+
async function generateMore(button, slideId, input, countSelect) {
|
|
419
479
|
if (isClosed || pendingGenerate.size > 0) return;
|
|
420
480
|
|
|
421
481
|
const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
|
|
@@ -428,21 +488,42 @@ async function generateMore(button, slideId, input) {
|
|
|
428
488
|
|
|
429
489
|
const prompt = input ? input.value.trim() : "";
|
|
430
490
|
if (input) input.value = "";
|
|
491
|
+
|
|
492
|
+
const count = countSelect ? parseInt(countSelect.value, 10) || 1 : 1;
|
|
493
|
+
|
|
494
|
+
// Create skeletons for each option being generated
|
|
495
|
+
const skeletons = [];
|
|
496
|
+
for (let i = 0; i < count; i++) {
|
|
497
|
+
const skeleton = createElement("div", "option-skeleton");
|
|
498
|
+
optionsGrid.appendChild(skeleton);
|
|
499
|
+
skeletons.push(skeleton);
|
|
500
|
+
}
|
|
431
501
|
|
|
432
|
-
const skeleton = createElement("div", "option-skeleton");
|
|
433
|
-
optionsGrid.appendChild(skeleton);
|
|
434
|
-
|
|
435
|
-
const originalText = button.childNodes[1] ? button.childNodes[1].textContent || "" : "";
|
|
436
502
|
button.classList.add("loading");
|
|
437
503
|
const plus = button.querySelector(".btn-gen-plus");
|
|
438
504
|
if (plus) plus.textContent = "";
|
|
439
|
-
|
|
505
|
+
button.lastChild.textContent = "Generating...";
|
|
440
506
|
if (input) input.disabled = true;
|
|
507
|
+
if (countSelect) countSelect.disabled = true;
|
|
508
|
+
|
|
509
|
+
// Set timeout for generation (30s per option)
|
|
510
|
+
const timeoutId = setTimeout(() => {
|
|
511
|
+
const pending = pendingGenerate.get(slideId);
|
|
512
|
+
if (pending && !pending.isRegen) {
|
|
513
|
+
const received = pending.receivedCount || 0;
|
|
514
|
+
restoreGenerateButton(slideId);
|
|
515
|
+
if (received === 0) {
|
|
516
|
+
showSaveToast("Generation timed out — try again", true);
|
|
517
|
+
} else {
|
|
518
|
+
showSaveToast(`Generated ${received} of ${count} options`, true);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}, 30000 * count);
|
|
441
522
|
|
|
442
|
-
pendingGenerate.set(slideId, { button,
|
|
523
|
+
pendingGenerate.set(slideId, { button, skeletons, input, countSelect, model: selectedModel || null, expectedCount: count, receivedCount: 0, timeoutId });
|
|
443
524
|
|
|
444
525
|
try {
|
|
445
|
-
const body = { token: sessionToken, slideId };
|
|
526
|
+
const body = { token: sessionToken, slideId, count };
|
|
446
527
|
if (prompt) body.prompt = prompt;
|
|
447
528
|
if (hasModelBar) {
|
|
448
529
|
body.model = selectedModel;
|
|
@@ -470,8 +551,21 @@ async function regenerateSlide(button, slideId) {
|
|
|
470
551
|
const prompt = input ? input.value.trim() : "";
|
|
471
552
|
if (input) input.value = "";
|
|
472
553
|
|
|
473
|
-
|
|
474
|
-
|
|
554
|
+
// Create skeleton overlay
|
|
555
|
+
const optionCount = slide.options?.length || 2;
|
|
556
|
+
const colClass = optionsGrid.classList.contains("cols-3") ? "cols-3" :
|
|
557
|
+
optionsGrid.classList.contains("cols-1") ? "cols-1" : "cols-2";
|
|
558
|
+
const overlay = createElement("div", `regen-overlay ${colClass}`);
|
|
559
|
+
for (let i = 0; i < optionCount; i++) {
|
|
560
|
+
overlay.appendChild(createElement("div", "regen-skeleton"));
|
|
561
|
+
}
|
|
562
|
+
const status = createElement("div", "regen-status", "Regenerating options...");
|
|
563
|
+
overlay.appendChild(status);
|
|
564
|
+
|
|
565
|
+
// Position overlay relative to options grid
|
|
566
|
+
optionsGrid.style.position = "relative";
|
|
567
|
+
optionsGrid.classList.add("regenerating");
|
|
568
|
+
optionsGrid.appendChild(overlay);
|
|
475
569
|
|
|
476
570
|
const originalText = button.textContent || "";
|
|
477
571
|
button.classList.add("loading");
|
|
@@ -482,7 +576,7 @@ async function regenerateSlide(button, slideId) {
|
|
|
482
576
|
const genMoreBtn = slideElement.querySelector(".btn-gen-more");
|
|
483
577
|
if (genMoreBtn) genMoreBtn.disabled = true;
|
|
484
578
|
|
|
485
|
-
pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn });
|
|
579
|
+
pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn, overlay });
|
|
486
580
|
|
|
487
581
|
try {
|
|
488
582
|
const body = { token: sessionToken, slideId };
|
|
@@ -502,7 +596,7 @@ function restoreRegenButton(slideId) {
|
|
|
502
596
|
if (!pending || !pending.isRegen) return;
|
|
503
597
|
pendingGenerate.delete(slideId);
|
|
504
598
|
|
|
505
|
-
const { button, originalText, input, optionsGrid, genMoreBtn } = pending;
|
|
599
|
+
const { button, originalText, input, optionsGrid, genMoreBtn, overlay } = pending;
|
|
506
600
|
if (button) {
|
|
507
601
|
button.classList.remove("loading");
|
|
508
602
|
button.disabled = false;
|
|
@@ -510,8 +604,11 @@ function restoreRegenButton(slideId) {
|
|
|
510
604
|
}
|
|
511
605
|
if (input) input.disabled = false;
|
|
512
606
|
if (optionsGrid) {
|
|
513
|
-
optionsGrid.
|
|
514
|
-
optionsGrid.style.
|
|
607
|
+
optionsGrid.classList.remove("regenerating");
|
|
608
|
+
optionsGrid.style.position = "";
|
|
609
|
+
}
|
|
610
|
+
if (overlay && overlay.parentElement) {
|
|
611
|
+
overlay.remove();
|
|
515
612
|
}
|
|
516
613
|
if (genMoreBtn) genMoreBtn.disabled = false;
|
|
517
614
|
}
|
package/generate-prompts.ts
CHANGED
|
@@ -36,7 +36,7 @@ function modelHints(generateModel?: string, thinking?: string, action?: string):
|
|
|
36
36
|
return hint;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string): string {
|
|
39
|
+
export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string, count: number = 1): string {
|
|
40
40
|
const title = slide?.title ?? slideId;
|
|
41
41
|
const context = slide?.context ? `\nContext: ${slide.context}` : "";
|
|
42
42
|
|
|
@@ -54,15 +54,22 @@ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | unde
|
|
|
54
54
|
const { hasBlocks, formatHint } = formatHintForSlide(slide);
|
|
55
55
|
const template = optionTemplate(hasBlocks);
|
|
56
56
|
const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
|
|
57
|
+
|
|
58
|
+
const optionWord = count === 1 ? "option" : "options";
|
|
59
|
+
const callInstructions = count === 1
|
|
60
|
+
? `YOU MUST generate one distinctive additional option and call design_deck with add-option.`
|
|
61
|
+
: `YOU MUST generate ${count} distinctive additional options. Call design_deck with add-option ${count} times (once per option).`;
|
|
57
62
|
|
|
58
63
|
return (
|
|
59
|
-
"The design deck is still open.\n\n" +
|
|
60
|
-
`User
|
|
64
|
+
"The design deck is still open and waiting for your response.\n\n" +
|
|
65
|
+
`User clicked "Generate ${count} ${optionWord}" for slide \"${title}\".${context}${userInstructions}\n\n` +
|
|
61
66
|
`Existing options:\n${existingText}\n\n` +
|
|
62
|
-
|
|
67
|
+
`${callInstructions} ` +
|
|
68
|
+
`Do not skip this step or decide the user has enough options — they explicitly requested ${count === 1 ? "another one" : `${count} more`}.${modelHints(generateModel, thinking, "add-option")}\n\n` +
|
|
63
69
|
`design_deck({\"action\":\"add-option\",\"slideId\":\"${slideId}\",\"option\":${JSON.stringify(template)}})\n\n` +
|
|
64
70
|
`The option field must be a JSON string with: label, optional description, optional aside (explanatory notes below preview), and optional recommended.\n` +
|
|
65
|
-
`${formatHint}`
|
|
71
|
+
`${formatHint}` +
|
|
72
|
+
(count > 1 ? `\n\nRemember: Call add-option ${count} times, each with a different option. Make each option distinctive.` : "")
|
|
66
73
|
);
|
|
67
74
|
}
|
|
68
75
|
|
|
@@ -75,9 +82,10 @@ export function buildRegenerateResult(slideId: string, slide: DeckSlide | undefi
|
|
|
75
82
|
const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
|
|
76
83
|
|
|
77
84
|
return (
|
|
78
|
-
"The design deck is still open.\n\n" +
|
|
79
|
-
`User
|
|
80
|
-
`
|
|
85
|
+
"The design deck is still open and waiting for your response.\n\n" +
|
|
86
|
+
`User clicked "Regenerate all" for slide \"${title}\".${context}${userInstructions}\n\n` +
|
|
87
|
+
`YOU MUST generate ${optionCount} fresh, distinctive options and call design_deck with replace-options. ` +
|
|
88
|
+
`Do not skip this step — the user explicitly requested regeneration.${modelHints(generateModel, thinking, "replace-options")}\n\n` +
|
|
81
89
|
`design_deck({\"action\":\"replace-options\",\"slideId\":\"${slideId}\",\"options\":\"[${template}, ...]\"})` +
|
|
82
90
|
`\n\nThe options field must be a JSON string containing an array of ${optionCount} option objects.\n` +
|
|
83
91
|
`Each option needs: label, optional description, optional aside, optional recommended, and either previewHtml or previewBlocks.\n` +
|
package/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ interface DeckDetails {
|
|
|
42
42
|
status: "completed" | "cancelled" | "generate-more" | "aborted" | "error";
|
|
43
43
|
url: string;
|
|
44
44
|
selections?: Record<string, string>;
|
|
45
|
+
notes?: Record<string, string>;
|
|
46
|
+
finalNotes?: string;
|
|
45
47
|
slideId?: string;
|
|
46
48
|
reason?: string;
|
|
47
49
|
}
|
|
@@ -118,7 +120,7 @@ function clearDeckIdleTimer(): void {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
function cleanupActiveDeck(): void {
|
|
123
|
+
function cleanupActiveDeck(reason?: string): void {
|
|
122
124
|
clearDeckIdleTimer();
|
|
123
125
|
if (restoreDeckThinking) {
|
|
124
126
|
restoreDeckThinking();
|
|
@@ -126,20 +128,23 @@ function cleanupActiveDeck(): void {
|
|
|
126
128
|
}
|
|
127
129
|
if (!activeDeckServer) return;
|
|
128
130
|
try {
|
|
129
|
-
activeDeckServer.handle.close();
|
|
131
|
+
activeDeckServer.handle.close(reason);
|
|
130
132
|
} catch {}
|
|
131
133
|
activeDeckServer = null;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function cleanupActiveDeckAndStoreResult(result: DeckToolResult): void {
|
|
135
137
|
if (!activeDeckServer) return;
|
|
138
|
+
// Extract close reason from result details
|
|
139
|
+
const details = result.details as DeckDetails | undefined;
|
|
140
|
+
const closeReason = details?.status === "aborted" ? "aborted" : details?.reason;
|
|
136
141
|
if (activeDeckServer.currentResolve) {
|
|
137
142
|
const resolve = activeDeckServer.currentResolve;
|
|
138
|
-
cleanupActiveDeck();
|
|
143
|
+
cleanupActiveDeck(closeReason);
|
|
139
144
|
resolve(result);
|
|
140
145
|
} else {
|
|
141
146
|
pendingDeckResult = result;
|
|
142
|
-
cleanupActiveDeck();
|
|
147
|
+
cleanupActiveDeck(closeReason);
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
|
|
@@ -181,10 +186,16 @@ function attachDeckAbortHandler(signal: AbortSignal | undefined): void {
|
|
|
181
186
|
signal.addEventListener("abort", abortHandler, { once: true });
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
function formatDeckSelections(selections: Record<string, string>): string {
|
|
189
|
+
function formatDeckSelections(selections: Record<string, string>, notes?: Record<string, string>): string {
|
|
185
190
|
const entries = Object.entries(selections);
|
|
186
191
|
if (entries.length === 0) return "(none)";
|
|
187
|
-
return entries.map(([key, value]) =>
|
|
192
|
+
return entries.map(([key, value]) => {
|
|
193
|
+
const note = notes?.[key];
|
|
194
|
+
if (note) {
|
|
195
|
+
return `- ${key}: ${value}\n Notes: ${note}`;
|
|
196
|
+
}
|
|
197
|
+
return `- ${key}: ${value}`;
|
|
198
|
+
}).join("\n");
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
export default function (pi: ExtensionAPI) {
|
|
@@ -274,17 +285,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
285
|
};
|
|
275
286
|
}
|
|
276
287
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
content: [
|
|
280
|
-
{
|
|
281
|
-
type: "text",
|
|
282
|
-
text: "Design deck is not waiting for a generated option right now.",
|
|
283
|
-
},
|
|
284
|
-
],
|
|
285
|
-
details: { status: "error", url: activeDeckServer.handle.url },
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
+
// Note: We don't check currentResolve here because multiple parallel
|
|
289
|
+
// add-option calls are valid (e.g., user requests 3 options at once)
|
|
288
290
|
|
|
289
291
|
const slideId = p.slideId as string;
|
|
290
292
|
const option = p.option as string;
|
|
@@ -320,14 +322,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
322
|
};
|
|
321
323
|
}
|
|
322
324
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
325
|
+
// For add-option, return immediately without blocking.
|
|
326
|
+
// This allows parallel add-option calls to all succeed.
|
|
327
|
+
// The deck stays open and will send a new prompt when the user
|
|
328
|
+
// clicks generate-more again or submits.
|
|
329
|
+
return {
|
|
330
|
+
content: [{ type: "text", text: `Pushed option "${parsedOption.label}" to slide ${slideId}.` }],
|
|
331
|
+
details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
|
|
332
|
+
};
|
|
331
333
|
}
|
|
332
334
|
|
|
333
335
|
if (p.action === "replace-options") {
|
|
@@ -472,17 +474,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
472
474
|
};
|
|
473
475
|
}
|
|
474
476
|
|
|
475
|
-
const handleSubmit = (selections: Record<string, string
|
|
477
|
+
const handleSubmit = (selections: Record<string, string>, notes?: Record<string, string>, finalNotes?: string) => {
|
|
476
478
|
if (!activeDeckServer) return;
|
|
477
479
|
const url = activeDeckServer.handle.url;
|
|
480
|
+
const hasNotes = notes && Object.keys(notes).length > 0;
|
|
481
|
+
const textParts = [`Design deck completed.\n\nSelections:\n${formatDeckSelections(selections, notes)}`];
|
|
482
|
+
if (finalNotes) {
|
|
483
|
+
textParts.push(`\nAdditional instructions:\n${finalNotes}`);
|
|
484
|
+
}
|
|
478
485
|
cleanupActiveDeckAndStoreResult({
|
|
479
486
|
content: [
|
|
480
487
|
{
|
|
481
488
|
type: "text",
|
|
482
|
-
text:
|
|
489
|
+
text: textParts.join(""),
|
|
483
490
|
},
|
|
484
491
|
],
|
|
485
|
-
details: {
|
|
492
|
+
details: {
|
|
493
|
+
status: "completed",
|
|
494
|
+
url,
|
|
495
|
+
selections,
|
|
496
|
+
...(hasNotes ? { notes } : {}),
|
|
497
|
+
...(finalNotes ? { finalNotes } : {}),
|
|
498
|
+
},
|
|
486
499
|
});
|
|
487
500
|
};
|
|
488
501
|
|
|
@@ -503,8 +516,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
503
516
|
});
|
|
504
517
|
};
|
|
505
518
|
|
|
506
|
-
const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
|
|
507
|
-
if (!activeDeckServer?.currentResolve)
|
|
519
|
+
const handleGenerateMore = (slideId: string, prompt?: string, model?: string, thinking?: string, count?: number) => {
|
|
520
|
+
if (!activeDeckServer?.currentResolve) {
|
|
521
|
+
// Agent is no longer listening - close the deck
|
|
522
|
+
cleanupActiveDeck("stale");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
508
525
|
const resolve = activeDeckServer.currentResolve;
|
|
509
526
|
activeDeckServer.currentResolve = null;
|
|
510
527
|
armDeckIdleTimer();
|
|
@@ -513,8 +530,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
513
530
|
if (thinking && !effectiveModel) {
|
|
514
531
|
pi.setThinkingLevel(thinking as "off" | "minimal" | "low" | "medium" | "high" | "xhigh");
|
|
515
532
|
}
|
|
533
|
+
const effectiveCount = count && count >= 1 && count <= 5 ? count : 1;
|
|
516
534
|
resolve({
|
|
517
|
-
content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking) }],
|
|
535
|
+
content: [{ type: "text", text: buildGenerateMoreResult(slideId, slide, prompt, effectiveModel, thinking, effectiveCount) }],
|
|
518
536
|
details: {
|
|
519
537
|
status: "generate-more",
|
|
520
538
|
url: activeDeckServer.handle.url,
|
|
@@ -524,7 +542,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
542
|
};
|
|
525
543
|
|
|
526
544
|
const handleRegenerateSlide = (slideId: string, prompt?: string, model?: string, thinking?: string) => {
|
|
527
|
-
if (!activeDeckServer?.currentResolve)
|
|
545
|
+
if (!activeDeckServer?.currentResolve) {
|
|
546
|
+
// Agent is no longer listening - close the deck
|
|
547
|
+
cleanupActiveDeck("stale");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
528
550
|
const resolve = activeDeckServer.currentResolve;
|
|
529
551
|
activeDeckServer.currentResolve = null;
|
|
530
552
|
armDeckIdleTimer();
|