vibespot 1.1.0 → 1.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/ui/chat.js CHANGED
@@ -18,12 +18,38 @@ let currentTemplateId = "";
18
18
  let renderScheduled = false;
19
19
  let scrollScheduled = false;
20
20
 
21
+ // Change tracking for the in-flight generation. `modulesBeforeRun` snapshots
22
+ // the module list when the user submits a prompt so we can tell which modules
23
+ // are brand new when the run finishes. `changedModulesInRun` accumulates names
24
+ // from per-module `module_progress` events. The flag is set on
25
+ // `generation_complete` so the *next* `modules_updated` (the post-run one)
26
+ // triggers the highlight pass instead of a plain refresh.
27
+ let modulesBeforeRun = null;
28
+ let changedModulesInRun = new Set();
29
+ let highlightOnNextModulesUpdated = false;
30
+ let changedListClearTimer = null;
31
+
21
32
  const messagesEl = document.getElementById("chat-messages");
22
33
  const inputEl = document.getElementById("chat-input");
23
34
  const sendBtn = document.getElementById("chat-send");
24
35
  const statusText = document.getElementById("status-text");
25
36
  const statusEngine = document.getElementById("status-engine");
26
37
 
38
+ // Snapshot the welcome section before any init can destroy it
39
+ const _welcomeHtml = document.getElementById("chat-welcome")?.outerHTML || "";
40
+
41
+ function restoreWelcome() {
42
+ if (!_welcomeHtml || messagesEl.querySelector(".chat__welcome")) return;
43
+ messagesEl.insertAdjacentHTML("afterbegin", _welcomeHtml);
44
+ const el = messagesEl.querySelector("#starter-templates");
45
+ if (el) {
46
+ el.addEventListener("click", (e) => {
47
+ const btn = e.target.closest(".starter-btn");
48
+ if (btn) sendMessage(btn.dataset.prompt);
49
+ });
50
+ }
51
+ }
52
+
27
53
  // ---------------------------------------------------------------------------
28
54
  // WebSocket connection
29
55
  // ---------------------------------------------------------------------------
@@ -82,6 +108,11 @@ function handleWsMessage(msg) {
82
108
  messagesEl.innerHTML = "";
83
109
  document.getElementById("module-items").innerHTML = "";
84
110
  document.getElementById("module-count").textContent = "0";
111
+ hideChatSuggestions();
112
+ // Reset pipeline state — DOM nodes were detached by innerHTML clear
113
+ resetPipelineState();
114
+ stopPipelineTimer();
115
+ streamingMsgEl = null;
85
116
 
86
117
  if (msg.modules && msg.modules.length > 0) {
87
118
  updateModuleList(msg.modules);
@@ -106,6 +137,8 @@ function handleWsMessage(msg) {
106
137
  }
107
138
  }
108
139
  scrollToBottom();
140
+ } else {
141
+ restoreWelcome();
109
142
  }
110
143
 
111
144
  // Show/hide version history button
@@ -114,10 +147,44 @@ function handleWsMessage(msg) {
114
147
  historyBtn.style.display = msg.gitAvailable ? "" : "none";
115
148
  }
116
149
 
150
+ // Initialize history timeline (compact strip above chat input)
151
+ historyGitAvailable = !!msg.gitAvailable;
152
+ if (historyGitAvailable) {
153
+ historyTimelineCursor = 0;
154
+ refreshHistoryTimeline();
155
+ } else {
156
+ const tl = document.getElementById("history-timeline");
157
+ if (tl) tl.classList.add("hidden");
158
+ }
159
+
117
160
  // Hydrate plan-mode state (toggle + Plan pane content)
118
161
  if (window.planController) {
119
162
  window.planController.setInitialState(msg);
120
163
  }
164
+
165
+ // If a pipeline is actively running (reconnect scenario), lock the
166
+ // input so the user can't double-submit. Replayed pipeline events
167
+ // that follow this init message will rebuild the progress UI.
168
+ if (msg.isGenerating) {
169
+ isStreaming = true;
170
+ sendBtn.disabled = true;
171
+ streamStartTime = Date.now();
172
+ if (typeof window.setSelectModeDisabled === "function") {
173
+ window.setSelectModeDisabled(true);
174
+ }
175
+ }
176
+
177
+ // If setup handed us an initial prompt (describe-it path), send it now
178
+ // that the session is live. Skip if the project already has history
179
+ // (e.g. resumed session) to avoid double-submitting.
180
+ if (window.__pendingInitialPrompt) {
181
+ const pendingPrompt = window.__pendingInitialPrompt;
182
+ window.__pendingInitialPrompt = null;
183
+ const hasHistory = msg.messages && msg.messages.length > 0;
184
+ if (!hasHistory) {
185
+ setTimeout(() => sendMessage(pendingPrompt), 50);
186
+ }
187
+ }
121
188
  break;
122
189
 
123
190
  case "stream":
@@ -131,30 +198,40 @@ function handleWsMessage(msg) {
131
198
  case "generation_complete":
132
199
  clearStreamStatus();
133
200
  finishStreaming();
201
+ // The next `modules_updated` is the terminal one for this run — let it
202
+ // fire the change-highlight pass on the preview iframe.
203
+ highlightOnNextModulesUpdated = true;
134
204
  break;
135
205
 
136
206
  case "modules_updated":
137
207
  if (msg.modules) {
138
208
  updateModuleList(msg.modules);
139
209
  }
140
- refreshPreview();
210
+ if (highlightOnNextModulesUpdated) {
211
+ highlightOnNextModulesUpdated = false;
212
+ flushChangeHighlights(msg.modules || []);
213
+ } else {
214
+ refreshPreview();
215
+ }
141
216
  break;
142
217
 
143
218
  case "version_created":
144
219
  if (historyPanelOpen) refreshHistoryPanel();
220
+ // New generation: reset timeline cursor to head and refresh.
221
+ historyTimelineCursor = 0;
222
+ refreshHistoryTimeline();
145
223
  break;
146
224
 
147
225
  case "parse_warning":
148
- appendSystemMessage(msg.message || "Module changes could not be applied.");
226
+ appendSystemMessage(msg.message || "Section changes could not be applied.");
149
227
  break;
150
228
 
151
229
  case "error":
152
230
  stopPipelineTimer();
231
+ stopPipelineEstimate();
153
232
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
154
233
  finishStreaming();
155
- pipelineBubbleEl = null;
156
- pipelineStepsEl = null;
157
- pipelineModulesEl = null;
234
+ resetPipelineState();
158
235
  appendAssistantError(msg.message);
159
236
  setStatus("Error");
160
237
  break;
@@ -246,14 +323,73 @@ function handleWsMessage(msg) {
246
323
  const STEP_LABELS = {
247
324
  analyzing: "Analyzing",
248
325
  designing: "Designing",
249
- developing: "Developing",
326
+ developing: "Building",
250
327
  quality_check: "Quality Check",
251
328
  };
252
329
  const STEP_ORDER = ["analyzing", "designing", "developing", "quality_check"];
253
330
 
331
+ const STEP_ICONS = {
332
+ analyzing:
333
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
334
+ designing:
335
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20c1.4 0 2-.9 2-2 0-.5-.2-1-.5-1.4a1.5 1.5 0 0 1 1.2-2.4H17a5 5 0 0 0 5-5c0-5.5-4.5-9.2-10-9.2z"/><circle cx="7.5" cy="10.5" r="1.4"/><circle cx="12" cy="7.5" r="1.4"/><circle cx="16.5" cy="10.5" r="1.4"/></svg>',
336
+ developing:
337
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
338
+ quality_check:
339
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2 4 6v6c0 5 3.5 9.4 8 10 4.5-.6 8-5 8-10V6l-8-4z"/></svg>',
340
+ };
341
+
342
+ const CHECK_ICON =
343
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 13 10 18 20 7"/></svg>';
344
+
345
+ // Per-module wall-clock seconds. Rough heuristic for time-remaining display.
346
+ const ESTIMATED_SECONDS_PER_MODULE = 14;
347
+
254
348
  let pipelineBubbleEl = null;
255
- let pipelineStepsEl = null;
349
+ let pipelineStepperEl = null;
350
+ let pipelineDetailEl = null;
256
351
  let pipelineModulesEl = null;
352
+ let pipelineEstimateEl = null;
353
+ let pipelineCurrentStep = null;
354
+ let pipelineEstimateInterval = null;
355
+
356
+ function buildStepperHtml() {
357
+ const parts = [];
358
+ for (let i = 0; i < STEP_ORDER.length; i++) {
359
+ const step = STEP_ORDER[i];
360
+ parts.push(`
361
+ <div class="pipeline-stage pipeline-stage--pending" data-stage="${step}">
362
+ <div class="pipeline-stage__indicator">
363
+ <span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
364
+ <span class="pipeline-stage__check">${CHECK_ICON}</span>
365
+ </div>
366
+ <div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
367
+ </div>`);
368
+ if (i < STEP_ORDER.length - 1) {
369
+ parts.push('<div class="pipeline-stage__connector" data-after="' + step + '"></div>');
370
+ }
371
+ }
372
+ return `<div class="pipeline-stepper" role="progressbar" aria-label="Generation progress">${parts.join("")}</div>`;
373
+ }
374
+
375
+ function buildPipelineBodyHtml() {
376
+ return `
377
+ ${buildStepperHtml()}
378
+ <div class="pipeline-detail" hidden></div>
379
+ <div class="pipeline-modules"></div>
380
+ <div class="pipeline-footer">
381
+ <div class="pipeline-timer"></div>
382
+ <div class="pipeline-estimate" hidden></div>
383
+ </div>`;
384
+ }
385
+
386
+ function captureBubbleRefs(bubbleEl, container) {
387
+ pipelineBubbleEl = container;
388
+ pipelineStepperEl = bubbleEl.querySelector(".pipeline-stepper");
389
+ pipelineDetailEl = bubbleEl.querySelector(".pipeline-detail");
390
+ pipelineModulesEl = bubbleEl.querySelector(".pipeline-modules");
391
+ pipelineEstimateEl = bubbleEl.querySelector(".pipeline-estimate");
392
+ }
257
393
 
258
394
  function ensurePipelineBubble() {
259
395
  if (pipelineBubbleEl) return;
@@ -270,13 +406,8 @@ function ensurePipelineBubble() {
270
406
  if (streamingMsgEl) {
271
407
  const existingDiv = streamingMsgEl.closest(".chat-msg");
272
408
  if (existingDiv) {
273
- streamingMsgEl.innerHTML = `
274
- <div class="pipeline-steps"></div>
275
- <div class="pipeline-modules"></div>
276
- <div class="pipeline-timer"></div>`;
277
- pipelineBubbleEl = existingDiv;
278
- pipelineStepsEl = streamingMsgEl.querySelector(".pipeline-steps");
279
- pipelineModulesEl = streamingMsgEl.querySelector(".pipeline-modules");
409
+ streamingMsgEl.innerHTML = buildPipelineBodyHtml();
410
+ captureBubbleRefs(streamingMsgEl, existingDiv);
280
411
  startPipelineTimer(streamingMsgEl.querySelector(".pipeline-timer"));
281
412
  scrollToBottom();
282
413
  return;
@@ -294,64 +425,80 @@ function ensurePipelineBubble() {
294
425
  <span class="chat-msg__time">${time}</span>
295
426
  </div>
296
427
  <div class="chat-msg__bubble">
297
- <div class="pipeline-steps"></div>
298
- <div class="pipeline-modules"></div>
299
- <div class="pipeline-timer"></div>
428
+ ${buildPipelineBodyHtml()}
300
429
  </div>
301
430
  </div>`;
302
431
  messagesEl.appendChild(div);
303
432
 
304
- pipelineBubbleEl = div;
305
- pipelineStepsEl = div.querySelector(".pipeline-steps");
306
- pipelineModulesEl = div.querySelector(".pipeline-modules");
307
433
  streamingMsgEl = div.querySelector(".chat-msg__bubble");
434
+ captureBubbleRefs(streamingMsgEl, div);
308
435
  startPipelineTimer(div.querySelector(".pipeline-timer"));
309
436
  scrollToBottom();
310
437
  }
311
438
 
312
- function markStepDone(el) {
313
- el.classList.add("pipeline-step--done");
314
- el.classList.remove("pipeline-step--active");
315
- const icon = el.querySelector(".pipeline-step__icon");
316
- if (icon) icon.textContent = "✓";
439
+ function setStageStatus(step, status) {
440
+ if (!pipelineStepperEl) return;
441
+ const stage = pipelineStepperEl.querySelector(`[data-stage="${step}"]`);
442
+ if (!stage) return;
443
+ stage.classList.remove(
444
+ "pipeline-stage--pending",
445
+ "pipeline-stage--active",
446
+ "pipeline-stage--done",
447
+ "pipeline-stage--failed",
448
+ );
449
+ stage.classList.add("pipeline-stage--" + status);
450
+
451
+ // Fill the connector behind this stage when it becomes active or done.
452
+ const idx = STEP_ORDER.indexOf(step);
453
+ if (idx > 0) {
454
+ const prev = STEP_ORDER[idx - 1];
455
+ const conn = pipelineStepperEl.querySelector(`[data-after="${prev}"]`);
456
+ if (conn) conn.classList.add("pipeline-stage__connector--filled");
457
+ }
458
+ }
459
+
460
+ function setPipelineDetail(text) {
461
+ if (!pipelineDetailEl) return;
462
+ if (!text) {
463
+ pipelineDetailEl.hidden = true;
464
+ pipelineDetailEl.textContent = "";
465
+ return;
466
+ }
467
+ pipelineDetailEl.hidden = false;
468
+ pipelineDetailEl.classList.remove("pipeline-detail--in");
469
+ // Force reflow so the animation restarts when text changes
470
+ void pipelineDetailEl.offsetWidth;
471
+ pipelineDetailEl.classList.add("pipeline-detail--in");
472
+ pipelineDetailEl.textContent = text;
317
473
  }
318
474
 
319
475
  function handleAgentStep(msg) {
320
476
  ensurePipelineBubble();
321
477
 
322
- // If the same step fires again (e.g., "designing" fires twice for design system + module planner),
323
- // update the existing step's label instead of creating a duplicate
324
- const existingStep = pipelineStepsEl.querySelector(`[data-step="${CSS.escape(msg.step)}"]:not(.pipeline-step--done)`);
325
- if (existingStep) {
326
- // Mark the current one as done and create a fresh one below
327
- markStepDone(existingStep);
328
- } else {
329
- // Mark all other active steps as done
330
- const existing = pipelineStepsEl.querySelectorAll(".pipeline-step");
331
- existing.forEach((el) => {
332
- if (!el.classList.contains("pipeline-step--done")) {
333
- markStepDone(el);
478
+ const incoming = msg.step;
479
+ const incomingIdx = STEP_ORDER.indexOf(incoming);
480
+
481
+ // Mark all prior stages done (handles forward jumps + repeated firings)
482
+ if (incomingIdx >= 0) {
483
+ for (let i = 0; i < incomingIdx; i++) {
484
+ const prevStage = pipelineStepperEl.querySelector(`[data-stage="${STEP_ORDER[i]}"]`);
485
+ if (prevStage && !prevStage.classList.contains("pipeline-stage--done")) {
486
+ setStageStatus(STEP_ORDER[i], "done");
334
487
  }
335
- });
488
+ }
336
489
  }
337
490
 
338
- // Add new step
339
- const step = document.createElement("div");
340
- step.className = "pipeline-step pipeline-step--active";
341
- step.dataset.step = msg.step;
342
- step.innerHTML = `<span class="pipeline-step__icon">⟳</span> <span class="pipeline-step__label">${msg.label || STEP_LABELS[msg.step] || msg.step}</span>`;
491
+ setStageStatus(incoming, "active");
492
+ pipelineCurrentStep = incoming;
343
493
 
344
- // Insert quality_check AFTER module cards so the visual order is:
345
- // developing → module cards → quality check
346
- if (msg.step === "quality_check" && pipelineModulesEl) {
347
- pipelineModulesEl.after(step);
348
- } else {
349
- pipelineStepsEl.appendChild(step);
350
- }
494
+ setPipelineDetail(msg.label || STEP_LABELS[incoming] || incoming);
351
495
 
352
- // Clear module cards when entering developing
353
- if (msg.step === "developing") {
496
+ if (incoming === "developing") {
354
497
  pipelineModulesEl.innerHTML = "";
498
+ startPipelineEstimate();
499
+ } else {
500
+ stopPipelineEstimate();
501
+ if (pipelineEstimateEl) pipelineEstimateEl.hidden = true;
355
502
  }
356
503
 
357
504
  scrollToBottom();
@@ -359,17 +506,7 @@ function handleAgentStep(msg) {
359
506
 
360
507
  function handleAgentDecision(msg) {
361
508
  if (!pipelineBubbleEl) return;
362
-
363
- // Find the step element — may be inside pipelineStepsEl or after pipelineModulesEl
364
- const bubble = streamingMsgEl || pipelineBubbleEl;
365
- const steps = bubble.querySelectorAll(".pipeline-step");
366
- const lastStep = steps[steps.length - 1];
367
- if (lastStep) {
368
- const detail = document.createElement("div");
369
- detail.className = "pipeline-step__decision";
370
- detail.textContent = msg.decision;
371
- lastStep.appendChild(detail);
372
- }
509
+ setPipelineDetail(msg.decision);
373
510
  scrollToBottom();
374
511
  }
375
512
 
@@ -379,9 +516,18 @@ function handleModuleProgress(msg) {
379
516
  let card = pipelineModulesEl.querySelector(`[data-module="${CSS.escape(msg.module)}"]`);
380
517
  if (!card) {
381
518
  card = document.createElement("div");
382
- card.className = "pipeline-module-card";
383
519
  card.dataset.module = msg.module;
384
- card.innerHTML = `<span class="pipeline-module-card__name">${escapeHtml(msg.module)}</span> <span class="pipeline-module-card__status"></span>`;
520
+ card.innerHTML = `
521
+ <div class="pipeline-module-card__head">
522
+ <span class="pipeline-module-card__name">${escapeHtml(msg.module)}</span>
523
+ <span class="pipeline-module-card__status"></span>
524
+ </div>
525
+ <div class="pipeline-module-card__progress"><div class="pipeline-module-card__progress-bar"></div></div>
526
+ <div class="pipeline-module-card__skeleton">
527
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--lg"></div>
528
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--md"></div>
529
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--sm"></div>
530
+ </div>`;
385
531
  pipelineModulesEl.appendChild(card);
386
532
  }
387
533
 
@@ -390,39 +536,99 @@ function handleModuleProgress(msg) {
390
536
 
391
537
  const statusLabels = {
392
538
  queued: "queued",
393
- generating: "generating...",
394
- validating: "validating...",
395
- retrying: "retrying...",
396
- complete: "",
397
- failed: "",
539
+ generating: "generating",
540
+ validating: "validating",
541
+ retrying: "retrying",
542
+ complete: "done",
543
+ failed: "failed",
398
544
  };
399
- statusEl.textContent = statusLabels[msg.status] || msg.status;
545
+ if (statusEl) statusEl.textContent = statusLabels[msg.status] || msg.status;
400
546
 
401
- // Mark/clear working overlay in the preview
402
547
  if (msg.status === "generating" && typeof markModulesWorking === "function") {
403
548
  markModulesWorking([msg.module]);
404
549
  } else if ((msg.status === "complete" || msg.status === "failed") && typeof clearModuleWorking === "function") {
405
550
  clearModuleWorking(msg.module);
406
551
  }
407
552
 
553
+ updatePipelineEstimate();
554
+
555
+ if (msg.status === "complete" && msg.module) {
556
+ changedModulesInRun.add(msg.module);
557
+ }
558
+
408
559
  scrollToBottom();
409
560
  }
410
561
 
562
+ function startPipelineEstimate() {
563
+ stopPipelineEstimate();
564
+ if (!pipelineEstimateEl) return;
565
+ pipelineEstimateEl.hidden = false;
566
+ updatePipelineEstimate();
567
+ pipelineEstimateInterval = setInterval(updatePipelineEstimate, 1000);
568
+ }
569
+
570
+ function stopPipelineEstimate() {
571
+ if (pipelineEstimateInterval) {
572
+ clearInterval(pipelineEstimateInterval);
573
+ pipelineEstimateInterval = null;
574
+ }
575
+ }
576
+
577
+ function updatePipelineEstimate() {
578
+ if (!pipelineEstimateEl || pipelineCurrentStep !== "developing" || !pipelineModulesEl) return;
579
+ const cards = pipelineModulesEl.querySelectorAll(".pipeline-module-card");
580
+ if (!cards.length) {
581
+ pipelineEstimateEl.textContent = "Estimating…";
582
+ return;
583
+ }
584
+ let remaining = 0;
585
+ let inFlight = 0;
586
+ cards.forEach((c) => {
587
+ if (c.classList.contains("pipeline-module-card--complete") || c.classList.contains("pipeline-module-card--failed")) return;
588
+ remaining++;
589
+ if (
590
+ c.classList.contains("pipeline-module-card--generating") ||
591
+ c.classList.contains("pipeline-module-card--validating") ||
592
+ c.classList.contains("pipeline-module-card--retrying")
593
+ ) {
594
+ inFlight++;
595
+ }
596
+ });
597
+ if (remaining === 0) {
598
+ pipelineEstimateEl.textContent = "Wrapping up…";
599
+ return;
600
+ }
601
+ // Default concurrency limiter is 20 modules, but most chats only have a
602
+ // handful — assume 4-way effective parallelism for the estimate.
603
+ const parallel = Math.max(1, Math.min(4, inFlight || 1));
604
+ const seconds = Math.max(5, Math.ceil((remaining * ESTIMATED_SECONDS_PER_MODULE) / parallel));
605
+ pipelineEstimateEl.textContent = `~${formatEstimate(seconds)} remaining`;
606
+ }
607
+
608
+ function formatEstimate(totalSec) {
609
+ if (totalSec < 60) return totalSec + "s";
610
+ const m = Math.floor(totalSec / 60);
611
+ const s = totalSec % 60;
612
+ if (s === 0) return m + "m";
613
+ return m + "m " + s + "s";
614
+ }
615
+
411
616
  function handlePipelineComplete(msg) {
412
617
  if (!pipelineBubbleEl) return;
413
618
 
414
619
  stopPipelineTimer();
620
+ stopPipelineEstimate();
415
621
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
416
622
 
417
- // Remove the live timer element
418
- const timerEl = pipelineBubbleEl.querySelector(".pipeline-timer");
419
- if (timerEl) timerEl.remove();
623
+ STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
624
+ pipelineCurrentStep = null;
625
+ setPipelineDetail("");
626
+
627
+ const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
628
+ if (footer) footer.remove();
420
629
 
421
- // Mark all steps as done (search whole bubble since quality_check is outside pipelineStepsEl)
422
630
  const bubble = streamingMsgEl || pipelineBubbleEl;
423
- bubble.querySelectorAll(".pipeline-step").forEach((el) => markStepDone(el));
424
631
 
425
- // Show answer text for question intents
426
632
  if (msg.answer) {
427
633
  const answerEl = document.createElement("div");
428
634
  answerEl.className = "pipeline-answer";
@@ -430,69 +636,162 @@ function handlePipelineComplete(msg) {
430
636
  bubble.appendChild(answerEl);
431
637
  }
432
638
 
433
- // Add completion stats after the last element in the bubble
434
639
  const stats = document.createElement("div");
435
640
  stats.className = "pipeline-stats";
436
641
  const duration = formatDuration(msg.durationMs);
437
642
  if (msg.answer) {
438
- // For questions, just show duration
439
643
  stats.textContent = `Answered in ${duration}`;
440
644
  } else {
441
- stats.textContent = `Generated ${msg.modulesGenerated} module${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
645
+ stats.textContent = `Generated ${msg.modulesGenerated} section${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
442
646
  if (msg.modulesUnchanged > 0) {
443
647
  stats.textContent += ` (${msg.modulesUnchanged} unchanged)`;
444
648
  }
445
649
  }
446
- // Place stats after quality_check step (or after modules if no quality step)
447
- const qualityStep = bubble.querySelector('[data-step="quality_check"]');
448
- if (qualityStep) {
449
- qualityStep.after(stats);
450
- } else if (pipelineModulesEl) {
451
- pipelineModulesEl.after(stats);
452
- } else {
453
- bubble.appendChild(stats);
454
- }
650
+ bubble.appendChild(stats);
455
651
 
456
652
  clearStreamStatus();
457
653
  finishStreaming();
458
654
 
459
- // Reset pipeline state
460
- pipelineBubbleEl = null;
461
- pipelineStepsEl = null;
462
- pipelineModulesEl = null;
655
+ resetPipelineState();
463
656
  }
464
657
 
465
658
  function handlePipelinePartial(msg) {
466
659
  if (!pipelineBubbleEl) return;
467
660
 
468
661
  stopPipelineTimer();
662
+ stopPipelineEstimate();
469
663
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
470
664
 
471
- const timerEl = pipelineBubbleEl.querySelector(".pipeline-timer");
472
- if (timerEl) timerEl.remove();
665
+ STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
666
+ pipelineCurrentStep = null;
667
+ setPipelineDetail("");
668
+
669
+ const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
670
+ if (footer) footer.remove();
473
671
 
474
672
  const bubble = streamingMsgEl || pipelineBubbleEl;
475
- bubble.querySelectorAll(".pipeline-step").forEach((el) => markStepDone(el));
476
673
 
477
674
  const stats = document.createElement("div");
478
675
  stats.className = "pipeline-stats pipeline-stats--partial";
479
676
  const duration = formatDuration(msg.durationMs);
480
- stats.textContent = `${msg.succeeded.length} modules succeeded, ${msg.failed.length} failed in ${duration}`;
481
- const qualityStep = bubble.querySelector('[data-step="quality_check"]');
482
- if (qualityStep) {
483
- qualityStep.after(stats);
484
- } else if (pipelineModulesEl) {
485
- pipelineModulesEl.after(stats);
486
- } else {
487
- pipelineStepsEl.appendChild(stats);
488
- }
677
+ stats.textContent = `${msg.succeeded.length} sections succeeded, ${msg.failed.length} failed in ${duration}`;
678
+ bubble.appendChild(stats);
489
679
 
490
680
  clearStreamStatus();
491
681
  finishStreaming();
492
682
 
683
+ resetPipelineState();
684
+ }
685
+
686
+ function resetPipelineState() {
493
687
  pipelineBubbleEl = null;
494
- pipelineStepsEl = null;
688
+ pipelineStepperEl = null;
689
+ pipelineDetailEl = null;
495
690
  pipelineModulesEl = null;
691
+ pipelineEstimateEl = null;
692
+ pipelineCurrentStep = null;
693
+
694
+ renderChatSuggestions();
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Smart chat suggestions — contextual next-step chips after generation
699
+ // ---------------------------------------------------------------------------
700
+
701
+ let suggestionsVisible = false;
702
+
703
+ function getCurrentModuleNames() {
704
+ return Array.from(document.querySelectorAll("#module-items .module-item"))
705
+ .map((el) => (el.dataset.module || "").toLowerCase());
706
+ }
707
+
708
+ function pickContextualSuggestions(moduleNames) {
709
+ const has = (kw) => moduleNames.some((n) => n.includes(kw));
710
+ const additive = [];
711
+
712
+ if (!has("testimonial") && !has("review") && !has("quote")) {
713
+ additive.push("Add a testimonials section");
714
+ }
715
+ if (!has("pricing") && !has("plan") && !has("tier")) {
716
+ additive.push("Add a pricing table");
717
+ }
718
+ if (!has("faq") && !has("question")) {
719
+ additive.push("Add an FAQ section");
720
+ }
721
+ if (!has("contact") && !has("form")) {
722
+ additive.push("Add a contact form");
723
+ }
724
+ if (!has("hero") && !has("banner")) {
725
+ additive.push("Add a hero section");
726
+ }
727
+ if (!has("feature") && !has("benefit")) {
728
+ additive.push("Add a features grid with icons");
729
+ }
730
+ if (!has("footer")) {
731
+ additive.push("Add a footer with social links");
732
+ }
733
+ if (!has("stat") && !has("metric") && !has("number")) {
734
+ additive.push("Add a stats section with key numbers");
735
+ }
736
+ if (!has("cta") && !has("call")) {
737
+ additive.push("Add a call-to-action section");
738
+ }
739
+
740
+ const refinements = [
741
+ has("hero") || has("banner")
742
+ ? "Make the hero CTA more prominent"
743
+ : "Strengthen the opening hook",
744
+ "Change the color scheme to something bolder",
745
+ "Refine the typography for a more modern feel",
746
+ "Add subtle animations to bring it to life",
747
+ ];
748
+
749
+ const out = [];
750
+ for (const s of additive) {
751
+ if (out.length >= 3) break;
752
+ out.push(s);
753
+ }
754
+ for (const r of refinements) {
755
+ if (out.length >= 3) break;
756
+ out.push(r);
757
+ }
758
+ return out;
759
+ }
760
+
761
+ function renderChatSuggestions() {
762
+ const container = document.getElementById("chat-suggestions");
763
+ if (!container) return;
764
+
765
+ if (isStreaming || (inputEl && inputEl.value && inputEl.value.length > 0)) {
766
+ hideChatSuggestions();
767
+ return;
768
+ }
769
+
770
+ const suggestions = pickContextualSuggestions(getCurrentModuleNames());
771
+ if (!suggestions.length) {
772
+ hideChatSuggestions();
773
+ return;
774
+ }
775
+
776
+ container.innerHTML = "";
777
+ for (const text of suggestions) {
778
+ const btn = document.createElement("button");
779
+ btn.type = "button";
780
+ btn.className = "chat__suggestion-chip";
781
+ btn.dataset.suggestion = text;
782
+ btn.textContent = text;
783
+ container.appendChild(btn);
784
+ }
785
+ container.classList.remove("hidden");
786
+ suggestionsVisible = true;
787
+ }
788
+
789
+ function hideChatSuggestions() {
790
+ const container = document.getElementById("chat-suggestions");
791
+ if (!container) return;
792
+ container.classList.add("hidden");
793
+ container.innerHTML = "";
794
+ suggestionsVisible = false;
496
795
  }
497
796
 
498
797
  async function handleAgenticPrompt() {
@@ -504,12 +803,12 @@ async function handleAgenticPrompt() {
504
803
  "vibeSpot can decompose AI generation into specialized agents:\n\n" +
505
804
  "• Intent Analyzer — classifies your request\n" +
506
805
  "• Page Architect — designs the page structure\n" +
507
- "• Module Developer — generates each module in parallel\n" +
806
+ "• Section Developer — generates each section in parallel\n" +
508
807
  "• Validator — checks and auto-fixes errors\n\n" +
509
808
  "Tradeoffs:\n" +
510
809
  "✓ Better quality — each agent is focused on one task\n" +
511
810
  "✓ Structured output — eliminates JSON parsing failures\n" +
512
- "✓ Only changed modules regenerated on edits\n" +
811
+ "✓ Only changed sections regenerated on edits\n" +
513
812
  "✗ Uses more calls per request (API calls or CLI subprocess calls)\n\n" +
514
813
  "You can change this anytime in Settings.",
515
814
  "Use Agentic Pipeline",
@@ -757,6 +1056,15 @@ async function sendMessage(text) {
757
1056
  // Show user message with file chips
758
1057
  appendUserMessage(text, null, uploadedFiles);
759
1058
 
1059
+ // Snapshot the current module list so we can detect new vs. modified modules
1060
+ // when the generation finishes. Clears stale change-indicator dots from any
1061
+ // previous run so the user only sees indicators for the run they just kicked.
1062
+ modulesBeforeRun = new Set(
1063
+ Array.from(document.querySelectorAll(".module-item")).map((el) => el.dataset.module),
1064
+ );
1065
+ changedModulesInRun = new Set();
1066
+ clearModuleListChanged();
1067
+
760
1068
  // Start streaming indicator
761
1069
  startStreaming();
762
1070
 
@@ -808,6 +1116,12 @@ function startStreaming() {
808
1116
  lastStreamStatus = "";
809
1117
  sendBtn.disabled = true;
810
1118
  streamStartTime = Date.now();
1119
+ if (typeof window.setSelectModeDisabled === "function") {
1120
+ window.setSelectModeDisabled(true);
1121
+ }
1122
+
1123
+ hideChatSuggestions();
1124
+ if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
811
1125
 
812
1126
  // Don't show generating preview here — agentic mode keeps the page visible.
813
1127
  // For single-call mode, showGeneratingPreview() is called on first "stream" event.
@@ -950,6 +1264,10 @@ function finishStreaming() {
950
1264
  if (!isStreaming) return;
951
1265
  isStreaming = false;
952
1266
  sendBtn.disabled = false;
1267
+ if (typeof window.setSelectModeDisabled === "function") {
1268
+ window.setSelectModeDisabled(false);
1269
+ }
1270
+ if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
953
1271
 
954
1272
  // Stop the timer and capture duration
955
1273
  stopStreamTimer();
@@ -975,7 +1293,7 @@ function finishStreaming() {
975
1293
  if (streamingMsgEl && streamBuffer) {
976
1294
  const rendered = renderMarkdown(streamBuffer);
977
1295
  const visibleText = rendered.replace(/<[^>]*>/g, "").trim();
978
- streamingMsgEl.innerHTML = visibleText ? rendered : "<em>Modules applied.</em>";
1296
+ streamingMsgEl.innerHTML = visibleText ? rendered : "<em>Sections applied.</em>";
979
1297
  }
980
1298
 
981
1299
  streamingMsgEl = null;
@@ -1081,25 +1399,44 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
1081
1399
  div.className = "chat-msg chat-msg--assistant";
1082
1400
 
1083
1401
  if (pipeline && pipeline.steps && pipeline.steps.length > 0) {
1084
- // Render detailed pipeline structure
1085
- const stepsHtml = pipeline.steps.map((s) => {
1086
- const icon = "&#x2714;";
1087
- const decisionsHtml = (s.decisions || [])
1088
- .map((d) => `<div class="pipeline-step__decision">${escapeHtml(d)}</div>`)
1089
- .join("");
1090
- return `<div class="pipeline-step pipeline-step--done"><span class="pipeline-step__icon">${icon}</span> <span class="pipeline-step__label">${escapeHtml(s.label)}</span>${decisionsHtml}</div>`;
1091
- }).join("");
1402
+ // Finalized stepper every visited stage shown as done
1403
+ const stagesSeen = new Set(pipeline.steps.map((s) => s.step));
1404
+ const stepperParts = [];
1405
+ for (let i = 0; i < STEP_ORDER.length; i++) {
1406
+ const step = STEP_ORDER[i];
1407
+ const visited = stagesSeen.has(step);
1408
+ const stateClass = visited ? "pipeline-stage--done" : "pipeline-stage--pending";
1409
+ stepperParts.push(`
1410
+ <div class="pipeline-stage ${stateClass}" data-stage="${step}">
1411
+ <div class="pipeline-stage__indicator">
1412
+ <span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
1413
+ <span class="pipeline-stage__check">${CHECK_ICON}</span>
1414
+ </div>
1415
+ <div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
1416
+ </div>`);
1417
+ if (i < STEP_ORDER.length - 1) {
1418
+ const filled = visited && stagesSeen.has(STEP_ORDER[i + 1]) ? "pipeline-stage__connector--filled" : "";
1419
+ stepperParts.push(`<div class="pipeline-stage__connector ${filled}" data-after="${step}"></div>`);
1420
+ }
1421
+ }
1422
+
1423
+ const allDecisions = pipeline.steps.flatMap((s) => s.decisions || []);
1424
+ const decisionHtml = allDecisions.length
1425
+ ? `<details class="pipeline-detail-summary"><summary>${allDecisions.length} decision${allDecisions.length === 1 ? "" : "s"} logged</summary>${allDecisions.map((d) => `<div class="pipeline-detail-summary__row">${escapeHtml(d)}</div>`).join("")}</details>`
1426
+ : "";
1092
1427
 
1093
1428
  const modulesHtml = pipeline.modules && pipeline.modules.length > 0
1094
- ? pipeline.modules.map((m) => {
1095
- const statusClass = m.status === "failed" ? "pipeline-module-card--failed" : "pipeline-module-card--done";
1096
- const statusIcon = m.status === "failed" ? "&#x2718;" : "&#x2714;";
1097
- return `<div class="pipeline-module-card ${statusClass}">${statusIcon} ${escapeHtml(m.name)}</div>`;
1098
- }).join("")
1429
+ ? `<div class="pipeline-modules pipeline-modules--restored">${pipeline.modules
1430
+ .map((m) => {
1431
+ const statusClass = m.status === "failed" ? "pipeline-module-card--failed" : "pipeline-module-card--complete";
1432
+ const statusLabel = m.status === "failed" ? "failed" : "done";
1433
+ return `<div class="pipeline-module-card ${statusClass}"><div class="pipeline-module-card__head"><span class="pipeline-module-card__name">${escapeHtml(m.name)}</span><span class="pipeline-module-card__status">${statusLabel}</span></div></div>`;
1434
+ })
1435
+ .join("")}</div>`
1099
1436
  : "";
1100
1437
 
1101
1438
  const duration = formatDuration(pipeline.stats.durationMs);
1102
- let statsText = `Generated ${pipeline.stats.modulesGenerated} module${pipeline.stats.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
1439
+ let statsText = `Generated ${pipeline.stats.modulesGenerated} section${pipeline.stats.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
1103
1440
  if (pipeline.stats.modulesUnchanged > 0) {
1104
1441
  statsText += ` (${pipeline.stats.modulesUnchanged} unchanged)`;
1105
1442
  }
@@ -1110,7 +1447,10 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
1110
1447
  <div class="chat-msg__content">
1111
1448
  ${time ? `<div class="chat-msg__header"><span class="chat-msg__sender">vibeSpot AI</span><span class="chat-msg__time">${time}</span></div>` : ""}
1112
1449
  <div class="chat-msg__bubble">
1113
- <div class="pipeline-steps">${stepsHtml}${modulesHtml ? `<div class="pipeline-modules-restored">${modulesHtml}</div>` : ""}<div class="${statsClass}">${statsText}</div></div>
1450
+ <div class="pipeline-stepper">${stepperParts.join("")}</div>
1451
+ ${decisionHtml}
1452
+ ${modulesHtml}
1453
+ <div class="${statsClass}">${statsText}</div>
1114
1454
  </div>
1115
1455
  </div>`;
1116
1456
  } else {
@@ -1234,7 +1574,7 @@ function attachHistoryToggle() {
1234
1574
  async function doRollback(hash) {
1235
1575
  const scoped = currentTemplateId && !historyShowAll;
1236
1576
  const msg = scoped
1237
- ? "This template's modules will be restored to the selected version. Other templates are not affected."
1577
+ ? "This template's sections will be restored to the selected version. Other templates are not affected."
1238
1578
  : "All theme files will be replaced, but chat history is preserved.";
1239
1579
  const ok = await vibeConfirm("Restore this version?", msg, { confirmLabel: "Restore", confirmClass: "btn--primary" });
1240
1580
  if (!ok) return;
@@ -1278,6 +1618,290 @@ function timeAgoShort(timestamp) {
1278
1618
  return days + "d";
1279
1619
  }
1280
1620
 
1621
+ // ---------------------------------------------------------------------------
1622
+ // History timeline — compact strip above chat input with undo/redo support
1623
+ // ---------------------------------------------------------------------------
1624
+
1625
+ let historyGitAvailable = false;
1626
+ // Index into historyTimelineEntries (filtered list, newest first) representing
1627
+ // the version the user is currently viewing. 0 = head, N = N steps back.
1628
+ let historyTimelineCursor = 0;
1629
+ let historyTimelineEntries = [];
1630
+ let historyTimelineRestoring = false;
1631
+
1632
+ function shortenCommitMessage(msg) {
1633
+ if (!msg) return "Update";
1634
+ let s = msg;
1635
+ // Strip [templateId] prefix
1636
+ const prefix = s.match(/^\[[^\]]+\]\s*/);
1637
+ if (prefix) s = s.slice(prefix[0].length);
1638
+ // Strip leading "Rollback to: "
1639
+ s = s.replace(/^Rollback to:\s*/i, "");
1640
+ if (s.length > 32) s = s.slice(0, 31) + "…";
1641
+ return s;
1642
+ }
1643
+
1644
+ function stripCommitPrefix(msg) {
1645
+ return (msg || "").replace(/^\[[^\]]+\]\s*/, "");
1646
+ }
1647
+
1648
+ // Compute which timeline entry represents the *currently visible* version.
1649
+ // HEAD may be a "Rollback to: <original>" commit (an internal marker we filter
1650
+ // out of the visible timeline); in that case, we map back to the original
1651
+ // entry by message. Otherwise the cursor sits at HEAD itself.
1652
+ function computeCursorFromHead(commits, entries) {
1653
+ if (!commits.length || !entries.length) return 0;
1654
+ const headMsg = stripCommitPrefix(commits[0].message);
1655
+ if (/^rollback to:\s*/i.test(headMsg)) {
1656
+ const orig = headMsg.replace(/^rollback to:\s*/i, "").trim().replace(/\.{3}$/, "");
1657
+ for (let i = 0; i < entries.length; i++) {
1658
+ const em = stripCommitPrefix(entries[i].message);
1659
+ if (em === orig || em.startsWith(orig)) return i;
1660
+ }
1661
+ return 0;
1662
+ }
1663
+ for (let i = 0; i < entries.length; i++) {
1664
+ if (entries[i].fullHash === commits[0].fullHash) return i;
1665
+ }
1666
+ return 0;
1667
+ }
1668
+
1669
+ async function refreshHistoryTimeline() {
1670
+ const tl = document.getElementById("history-timeline");
1671
+ const track = document.getElementById("history-timeline-track");
1672
+ if (!tl || !track) return;
1673
+
1674
+ if (!historyGitAvailable) {
1675
+ tl.classList.add("hidden");
1676
+ return;
1677
+ }
1678
+
1679
+ try {
1680
+ const useFilter = currentTemplateId;
1681
+ const url = useFilter
1682
+ ? `/api/history?templateId=${encodeURIComponent(currentTemplateId)}`
1683
+ : "/api/history";
1684
+ const res = await fetch(url);
1685
+ const data = await res.json();
1686
+ if (!data.available) {
1687
+ tl.classList.add("hidden");
1688
+ return;
1689
+ }
1690
+
1691
+ const allCommits = data.commits || [];
1692
+ // Filter out automatic "Rollback to:" commits — they're internal markers.
1693
+ // The user navigates the conceptual history of generation events.
1694
+ const entries = allCommits.filter((c) => {
1695
+ return !/^rollback to:\s*/i.test(stripCommitPrefix(c.message));
1696
+ });
1697
+ historyTimelineEntries = entries;
1698
+
1699
+ if (entries.length === 0) {
1700
+ tl.classList.add("hidden");
1701
+ return;
1702
+ }
1703
+
1704
+ historyTimelineCursor = computeCursorFromHead(allCommits, entries);
1705
+
1706
+ tl.classList.remove("hidden");
1707
+ track.innerHTML = "";
1708
+ const frag = document.createDocumentFragment();
1709
+ entries.forEach((commit, idx) => {
1710
+ const isInitial = (commit.message || "").startsWith("Initial ");
1711
+ const item = document.createElement("button");
1712
+ item.type = "button";
1713
+ item.className = "history-timeline__entry"
1714
+ + (idx === historyTimelineCursor ? " history-timeline__entry--current" : "")
1715
+ + (isInitial ? " history-timeline__entry--initial" : "");
1716
+ item.dataset.hash = commit.fullHash;
1717
+ item.dataset.index = String(idx);
1718
+ item.innerHTML = `<span class="history-timeline__entry-dot"></span>`
1719
+ + `<span class="history-timeline__entry-label"></span>`;
1720
+ item.querySelector(".history-timeline__entry-label").textContent = shortenCommitMessage(commit.message);
1721
+ item.title = ""; // we use a custom tooltip
1722
+ frag.appendChild(item);
1723
+ });
1724
+ track.appendChild(frag);
1725
+
1726
+ updateHistoryTimelineNavState();
1727
+
1728
+ // Scroll the current entry into view
1729
+ requestAnimationFrame(() => {
1730
+ const cur = track.querySelector(".history-timeline__entry--current");
1731
+ if (cur && typeof cur.scrollIntoView === "function") {
1732
+ cur.scrollIntoView({ block: "nearest", inline: "nearest" });
1733
+ }
1734
+ });
1735
+ } catch {
1736
+ tl.classList.add("hidden");
1737
+ }
1738
+ }
1739
+
1740
+ function updateHistoryTimelineNavState() {
1741
+ const undoBtn = document.getElementById("history-timeline-undo");
1742
+ const redoBtn = document.getElementById("history-timeline-redo");
1743
+ const max = historyTimelineEntries.length - 1;
1744
+ const busy = historyTimelineRestoring || (typeof isStreaming !== "undefined" && isStreaming);
1745
+ if (undoBtn) undoBtn.disabled = busy || historyTimelineCursor >= max;
1746
+ if (redoBtn) redoBtn.disabled = busy || historyTimelineCursor <= 0;
1747
+ }
1748
+
1749
+ async function restoreToTimelineIndex(idx) {
1750
+ if (historyTimelineRestoring) return;
1751
+ if (idx < 0 || idx >= historyTimelineEntries.length) return;
1752
+ if (typeof isStreaming !== "undefined" && isStreaming) return;
1753
+ const target = historyTimelineEntries[idx];
1754
+ if (!target) return;
1755
+
1756
+ historyTimelineRestoring = true;
1757
+ updateHistoryTimelineNavState();
1758
+ setStatus("Restoring…");
1759
+
1760
+ try {
1761
+ const payload = { hash: target.fullHash };
1762
+ if (currentTemplateId) payload.templateId = currentTemplateId;
1763
+ const res = await fetch("/api/rollback", {
1764
+ method: "POST",
1765
+ headers: { "Content-Type": "application/json" },
1766
+ body: JSON.stringify(payload),
1767
+ });
1768
+ const data = await res.json();
1769
+ if (data.error) {
1770
+ await vibeAlert(data.error, "Restore failed");
1771
+ setStatus("Ready");
1772
+ return;
1773
+ }
1774
+ if (data.modules) updateModuleList(data.modules);
1775
+ refreshPreview();
1776
+ // Refresh from server — the new HEAD is a "Rollback to:" commit pointing
1777
+ // at the entry we just restored; computeCursorFromHead resolves it back.
1778
+ await refreshHistoryTimeline();
1779
+ if (historyPanelOpen) refreshHistoryPanel();
1780
+ setStatus("Ready");
1781
+ } catch (err) {
1782
+ await vibeAlert(err.message || "Restore failed", "Restore failed");
1783
+ setStatus("Ready");
1784
+ } finally {
1785
+ historyTimelineRestoring = false;
1786
+ updateHistoryTimelineNavState();
1787
+ }
1788
+ }
1789
+
1790
+ function timelineUndo() {
1791
+ restoreToTimelineIndex(historyTimelineCursor + 1);
1792
+ }
1793
+
1794
+ function timelineRedo() {
1795
+ restoreToTimelineIndex(historyTimelineCursor - 1);
1796
+ }
1797
+
1798
+ // ---- Tooltip on hover ------------------------------------------------------
1799
+
1800
+ function showTimelineTooltip(entryEl) {
1801
+ const tooltip = document.getElementById("history-timeline-tooltip");
1802
+ const tl = document.getElementById("history-timeline");
1803
+ if (!tooltip || !tl || !entryEl) return;
1804
+ const idx = parseInt(entryEl.dataset.index || "-1", 10);
1805
+ const commit = historyTimelineEntries[idx];
1806
+ if (!commit) return;
1807
+
1808
+ const modules = (commit.changedModules || []).slice(0, 5);
1809
+ const more = (commit.changedModules || []).length - modules.length;
1810
+ const modulesLine = modules.length
1811
+ ? modules.join(", ") + (more > 0 ? ` +${more} more` : "")
1812
+ : "No section changes";
1813
+
1814
+ // Strip [templateId] prefix for display; keep "Rollback to:" prefix because
1815
+ // that case is filtered out earlier and won't reach here.
1816
+ let displayMsg = commit.message || "";
1817
+ const prefix = displayMsg.match(/^\[[^\]]+\]\s*/);
1818
+ if (prefix) displayMsg = displayMsg.slice(prefix[0].length);
1819
+
1820
+ tooltip.innerHTML = `
1821
+ <div class="history-timeline__tooltip-title"></div>
1822
+ <div class="history-timeline__tooltip-meta"></div>
1823
+ <div class="history-timeline__tooltip-modules"></div>
1824
+ `;
1825
+ tooltip.querySelector(".history-timeline__tooltip-title").textContent = displayMsg || "Update";
1826
+ tooltip.querySelector(".history-timeline__tooltip-meta").textContent =
1827
+ `${commit.hash} · ${timeAgoShort(commit.timestamp)} ago`;
1828
+ tooltip.querySelector(".history-timeline__tooltip-modules").textContent = modulesLine;
1829
+
1830
+ // Position above the entry, clamped within the timeline strip.
1831
+ tooltip.classList.remove("hidden");
1832
+ const tlRect = tl.getBoundingClientRect();
1833
+ const entryRect = entryEl.getBoundingClientRect();
1834
+ const left = Math.max(8, entryRect.left - tlRect.left);
1835
+ const tooltipW = tooltip.offsetWidth;
1836
+ const maxLeft = Math.max(8, tl.clientWidth - tooltipW - 8);
1837
+ tooltip.style.left = Math.min(left, maxLeft) + "px";
1838
+ }
1839
+
1840
+ function hideTimelineTooltip() {
1841
+ const tooltip = document.getElementById("history-timeline-tooltip");
1842
+ if (tooltip) tooltip.classList.add("hidden");
1843
+ }
1844
+
1845
+ // ---- Wire up DOM events ----------------------------------------------------
1846
+
1847
+ (function wireHistoryTimeline() {
1848
+ const track = document.getElementById("history-timeline-track");
1849
+ const undoBtn = document.getElementById("history-timeline-undo");
1850
+ const redoBtn = document.getElementById("history-timeline-redo");
1851
+
1852
+ if (track) {
1853
+ track.addEventListener("click", (e) => {
1854
+ const entry = e.target.closest(".history-timeline__entry");
1855
+ if (!entry) return;
1856
+ const idx = parseInt(entry.dataset.index || "-1", 10);
1857
+ if (idx >= 0) restoreToTimelineIndex(idx);
1858
+ });
1859
+
1860
+ let hoverTimer = null;
1861
+ track.addEventListener("mouseover", (e) => {
1862
+ const entry = e.target.closest(".history-timeline__entry");
1863
+ if (!entry) return;
1864
+ clearTimeout(hoverTimer);
1865
+ hoverTimer = setTimeout(() => showTimelineTooltip(entry), 200);
1866
+ });
1867
+ track.addEventListener("mouseout", (e) => {
1868
+ const entry = e.target.closest(".history-timeline__entry");
1869
+ if (!entry) return;
1870
+ clearTimeout(hoverTimer);
1871
+ hideTimelineTooltip();
1872
+ });
1873
+ }
1874
+
1875
+ if (undoBtn) undoBtn.addEventListener("click", timelineUndo);
1876
+ if (redoBtn) redoBtn.addEventListener("click", timelineRedo);
1877
+
1878
+ // Global Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z keyboard shortcuts. Skip when the
1879
+ // user is editing text so we never hijack native undo in inputs/editors.
1880
+ document.addEventListener("keydown", (e) => {
1881
+ const meta = e.metaKey || e.ctrlKey;
1882
+ if (!meta) return;
1883
+ const key = e.key.toLowerCase();
1884
+ if (key !== "z" && key !== "y") return;
1885
+
1886
+ const active = document.activeElement;
1887
+ if (active) {
1888
+ const tag = (active.tagName || "").toLowerCase();
1889
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
1890
+ if (active.isContentEditable) return;
1891
+ }
1892
+
1893
+ if (!historyGitAvailable) return;
1894
+
1895
+ if (key === "z" && !e.shiftKey) {
1896
+ e.preventDefault();
1897
+ timelineUndo();
1898
+ } else if ((key === "z" && e.shiftKey) || key === "y") {
1899
+ e.preventDefault();
1900
+ timelineRedo();
1901
+ }
1902
+ });
1903
+ })();
1904
+
1281
1905
  // ---------------------------------------------------------------------------
1282
1906
  // Module list
1283
1907
  // ---------------------------------------------------------------------------
@@ -1289,17 +1913,26 @@ function updateModuleList(moduleNames) {
1289
1913
 
1290
1914
  if (barCountEl) barCountEl.textContent = moduleNames.length;
1291
1915
  if (slideoutCountEl) slideoutCountEl.textContent = moduleNames.length;
1916
+
1917
+ // Preserve which items were marked as recently changed across re-renders so
1918
+ // the dots survive the per-module `modules_updated` events that happen
1919
+ // mid-run.
1920
+ const previouslyChanged = new Set(
1921
+ Array.from(itemsEl.querySelectorAll(".module-item--changed")).map((el) => el.dataset.module),
1922
+ );
1292
1923
  itemsEl.innerHTML = "";
1293
1924
 
1294
1925
  for (const name of moduleNames) {
1295
1926
  const item = document.createElement("div");
1296
1927
  item.className = "module-item";
1928
+ if (previouslyChanged.has(name)) item.classList.add("module-item--changed");
1297
1929
  item.dataset.module = name;
1298
1930
  item.innerHTML = `
1299
1931
  <span class="module-item__drag">⠿</span>
1932
+ <span class="module-item__changed-dot" aria-hidden="true"></span>
1300
1933
  <span class="module-item__name">${escapeHtml(name)}</span>
1301
1934
  <span class="module-item__edit" title="Edit fields">⚙</span>
1302
- <span class="module-item__delete" title="Delete module">&times;</span>
1935
+ <span class="module-item__delete" title="Delete section">&times;</span>
1303
1936
  `;
1304
1937
 
1305
1938
  item.querySelector(".module-item__edit").addEventListener("click", (e) => {
@@ -1325,6 +1958,72 @@ function highlightModuleItem(name) {
1325
1958
  });
1326
1959
  }
1327
1960
 
1961
+ /**
1962
+ * Flush change highlights at the end of a generation run: refresh the preview
1963
+ * with the changed/new module sets, mark the sidebar items, and schedule the
1964
+ * 30-second auto-clear of the dots.
1965
+ */
1966
+ function flushChangeHighlights(latestModules) {
1967
+ const changed = Array.from(changedModulesInRun);
1968
+ const before = modulesBeforeRun;
1969
+ changedModulesInRun = new Set();
1970
+ modulesBeforeRun = null;
1971
+
1972
+ let newModules = [];
1973
+ if (before) {
1974
+ if (changed.length > 0) {
1975
+ // Agentic pipeline: we know exactly which modules ran. New = ran AND
1976
+ // not in the pre-run snapshot.
1977
+ newModules = changed.filter((m) => !before.has(m));
1978
+ } else if (Array.isArray(latestModules)) {
1979
+ // Single-call mode emits no per-module events. Fall back to "names that
1980
+ // appeared since the run started" — we can't know which existing
1981
+ // modules were rewritten without server-side support.
1982
+ newModules = latestModules.filter((m) => !before.has(m));
1983
+ }
1984
+ }
1985
+
1986
+ refreshPreview({
1987
+ changedModules: changed.length > 0 ? changed : newModules,
1988
+ newModules,
1989
+ });
1990
+
1991
+ // Sidebar dots only fire when we have explicit per-module change info; the
1992
+ // single-call fallback above only knows about new modules, which already
1993
+ // get the slide-in animation in the preview.
1994
+ if (changed.length > 0) {
1995
+ markModuleListChanged(changed);
1996
+ } else if (newModules.length > 0) {
1997
+ markModuleListChanged(newModules);
1998
+ }
1999
+ }
2000
+
2001
+ /** Apply the change indicator dot to each named module in the sidebar. */
2002
+ function markModuleListChanged(names) {
2003
+ if (!names || names.length === 0) return;
2004
+ const itemsEl = document.getElementById("module-items");
2005
+ if (!itemsEl) return;
2006
+ const set = new Set(names);
2007
+ itemsEl.querySelectorAll(".module-item").forEach((el) => {
2008
+ if (set.has(el.dataset.module)) el.classList.add("module-item--changed");
2009
+ });
2010
+
2011
+ // Auto-clear after 30s per spec; next generation also clears via sendMessage.
2012
+ if (changedListClearTimer) clearTimeout(changedListClearTimer);
2013
+ changedListClearTimer = setTimeout(clearModuleListChanged, 30000);
2014
+ }
2015
+
2016
+ /** Remove all change indicator dots from the sidebar. */
2017
+ function clearModuleListChanged() {
2018
+ if (changedListClearTimer) {
2019
+ clearTimeout(changedListClearTimer);
2020
+ changedListClearTimer = null;
2021
+ }
2022
+ document.querySelectorAll(".module-item--changed").forEach((el) => {
2023
+ el.classList.remove("module-item--changed");
2024
+ });
2025
+ }
2026
+
1328
2027
  // ---------------------------------------------------------------------------
1329
2028
  // Module slideout
1330
2029
  // ---------------------------------------------------------------------------
@@ -1374,7 +2073,7 @@ async function toggleModuleLibraryDropdown() {
1374
2073
  );
1375
2074
 
1376
2075
  if (available.length === 0) {
1377
- dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other modules available</div>`;
2076
+ dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other sections available</div>`;
1378
2077
  } else {
1379
2078
  dropdown.innerHTML = available.map((m) =>
1380
2079
  `<button class="module-library-dropdown__item" data-name="${escapeHtml(m.moduleName)}">
@@ -1460,7 +2159,7 @@ function confirmDeleteModule(moduleName) {
1460
2159
  overlay.innerHTML = `
1461
2160
  <div class="confirm-dialog">
1462
2161
  <div class="confirm-dialog__title">Remove "${escapeHtml(moduleName)}"?</div>
1463
- <p class="confirm-dialog__detail">Module will be removed from this page but kept in your library.</p>
2162
+ <p class="confirm-dialog__detail">Section will be removed from this page but kept in your library.</p>
1464
2163
  <label class="confirm-dialog__toggle">
1465
2164
  <span class="confirm-dialog__toggle-switch">
1466
2165
  <input type="checkbox" data-role="toggle" />
@@ -1654,8 +2353,40 @@ inputEl.addEventListener("keydown", (e) => {
1654
2353
  inputEl.addEventListener("input", () => {
1655
2354
  inputEl.style.height = "auto";
1656
2355
  inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
2356
+
2357
+ // Hide suggestion chips as soon as the user starts typing
2358
+ if (suggestionsVisible && inputEl.value.length > 0) {
2359
+ hideChatSuggestions();
2360
+ }
1657
2361
  });
1658
2362
 
2363
+ // Suggestion chip click — pre-fill input and focus, do not auto-send so the
2364
+ // user can edit before pressing Enter.
2365
+ document.getElementById("chat-suggestions")?.addEventListener("click", (e) => {
2366
+ const chip = e.target.closest(".chat__suggestion-chip");
2367
+ if (!chip) return;
2368
+ const text = chip.dataset.suggestion || chip.textContent || "";
2369
+ inputEl.value = text;
2370
+ inputEl.focus();
2371
+ inputEl.setSelectionRange(text.length, text.length);
2372
+ // Trigger the input event so the textarea resizes; this also collapses chips
2373
+ inputEl.dispatchEvent(new Event("input", { bubbles: true }));
2374
+ hideChatSuggestions();
2375
+ });
2376
+
2377
+ // Pre-fill chat input from preview select-mode click
2378
+ window.prefillChatInput = function (text) {
2379
+ if (!text) return;
2380
+ const existing = inputEl.value;
2381
+ const prefix = existing.trim() ? existing.replace(/\s+$/, "") + "\n\n" : "";
2382
+ inputEl.value = prefix + text;
2383
+ inputEl.style.height = "auto";
2384
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
2385
+ inputEl.focus();
2386
+ const end = inputEl.value.length;
2387
+ inputEl.setSelectionRange(end, end);
2388
+ };
2389
+
1659
2390
  // Starter template buttons
1660
2391
  document.getElementById("starter-templates").addEventListener("click", (e) => {
1661
2392
  const btn = e.target.closest(".starter-btn");
@@ -1664,7 +2395,11 @@ document.getElementById("starter-templates").addEventListener("click", (e) => {
1664
2395
 
1665
2396
  // Templates icon in input area — toggle welcome section visibility
1666
2397
  document.getElementById("btn-starter-templates")?.addEventListener("click", () => {
1667
- const welcome = document.getElementById("chat-welcome");
2398
+ let welcome = document.getElementById("chat-welcome");
2399
+ if (!welcome) {
2400
+ restoreWelcome();
2401
+ welcome = document.getElementById("chat-welcome");
2402
+ }
1668
2403
  if (welcome) welcome.classList.toggle("hidden");
1669
2404
  });
1670
2405