vibespot 1.1.1 → 1.3.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/LICENSE +103 -33
- package/README.md +55 -6
- package/assets/blog-rules.md +251 -0
- package/assets/email-rules.md +390 -0
- package/assets/humanify-guide.md +300 -101
- package/assets/plan-templates/agency-services.md +42 -0
- package/assets/plan-templates/blog-content-hub.md +50 -0
- package/assets/plan-templates/ecommerce-product.md +42 -0
- package/assets/plan-templates/email-announcement.md +41 -0
- package/assets/plan-templates/email-event-invite.md +43 -0
- package/assets/plan-templates/email-newsletter.md +41 -0
- package/assets/plan-templates/email-re-engagement.md +42 -0
- package/assets/plan-templates/email-welcome.md +41 -0
- package/assets/plan-templates/event-registration.md +42 -0
- package/assets/plan-templates/portfolio.md +41 -0
- package/assets/plan-templates/restaurant.md +42 -0
- package/assets/plan-templates/saas-landing.md +42 -0
- package/dist/index.js +1485 -397
- package/dist/index.js.map +1 -1
- package/package.json +11 -7
- package/starters/01-saas-landing.json +43 -0
- package/starters/02-portfolio.json +39 -0
- package/starters/03-restaurant.json +39 -0
- package/starters/04-event.json +39 -0
- package/starters/05-coming-soon.json +32 -0
- package/starters/06-blog-content-hub.json +75 -0
- package/starters/06-email-welcome.json +60 -0
- package/starters/07-email-announcement.json +60 -0
- package/starters/08-email-newsletter.json +52 -0
- package/ui/chat.js +1604 -155
- package/ui/code-editor.js +49 -7
- package/ui/dashboard.js +551 -83
- package/ui/docs/docs.css +29 -0
- package/ui/docs/index.html +274 -117
- package/ui/docs/screenshots/brand-kit-preview.png +0 -0
- package/ui/docs/screenshots/content-type-dropdown.png +0 -0
- package/ui/docs/screenshots/editor-full-layout.png +0 -0
- package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
- package/ui/docs/screenshots/multi-page-tree.png +0 -0
- package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
- package/ui/docs/screenshots/split-pane-view.png +0 -0
- package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
- package/ui/docs/screenshots/workspace-tabs.png +0 -0
- package/ui/email-preview.js +109 -0
- package/ui/field-editor.js +71 -0
- package/ui/icons.js +120 -0
- package/ui/index.html +882 -515
- package/ui/inline-edit.js +710 -0
- package/ui/marketplace.js +218 -0
- package/ui/plan.js +0 -0
- package/ui/preview.js +219 -1
- package/ui/section-controls.js +628 -0
- package/ui/settings.js +84 -28
- package/ui/setup.js +1016 -118
- package/ui/styles.css +6119 -2456
- package/ui/upload-panel.js +47 -20
package/ui/chat.js
CHANGED
|
@@ -18,11 +18,506 @@ 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");
|
|
37
|
+
const statusTheme = document.getElementById("status-theme");
|
|
38
|
+
const statusLastSaved = document.getElementById("status-last-saved");
|
|
39
|
+
|
|
40
|
+
let lastSavedAt = 0;
|
|
41
|
+
|
|
42
|
+
// Snapshot the welcome section before any init can destroy it
|
|
43
|
+
const _welcomeHtml = document.getElementById("chat-welcome")?.outerHTML || "";
|
|
44
|
+
|
|
45
|
+
function restoreWelcome() {
|
|
46
|
+
if (!_welcomeHtml || messagesEl.querySelector(".chat__welcome")) return;
|
|
47
|
+
messagesEl.insertAdjacentHTML("afterbegin", _welcomeHtml);
|
|
48
|
+
const el = messagesEl.querySelector("#conversation-starters");
|
|
49
|
+
if (el) el.addEventListener("click", handleConversationStarterClick);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function handleConversationStarterClick(e) {
|
|
53
|
+
const btn = e.target.closest(".starter-btn");
|
|
54
|
+
if (!btn) return;
|
|
55
|
+
const action = btn.dataset.action;
|
|
56
|
+
if (action === "describe") {
|
|
57
|
+
inputEl?.focus();
|
|
58
|
+
} else if (action === "figma") {
|
|
59
|
+
document.getElementById("btn-attach-file")?.click();
|
|
60
|
+
} else if (action === "hubspot-import") {
|
|
61
|
+
if (inputEl) {
|
|
62
|
+
inputEl.value = "Import this HubSpot page and adapt it: ";
|
|
63
|
+
inputEl.style.height = "auto";
|
|
64
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
|
|
65
|
+
inputEl.focus();
|
|
66
|
+
const end = inputEl.value.length;
|
|
67
|
+
inputEl.setSelectionRange(end, end);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Page tabs (multi-page template switcher)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
let currentTemplates = [];
|
|
77
|
+
let pageDragSourceId = null;
|
|
78
|
+
|
|
79
|
+
const PAGE_TYPE_BADGES = {
|
|
80
|
+
landing_page: "LP",
|
|
81
|
+
blog_post: "Blog",
|
|
82
|
+
website_page: "Web",
|
|
83
|
+
email: "Email",
|
|
84
|
+
module_only: "Sec",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Inline SVG icons keyed by page type. Stroke-only so they inherit currentColor.
|
|
88
|
+
const PAGE_TYPE_ICONS = {
|
|
89
|
+
landing_page: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="9" x2="20" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/></svg>',
|
|
90
|
+
website_page: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><circle cx="6" cy="7" r="0.6" fill="currentColor"/><circle cx="8.4" cy="7" r="0.6" fill="currentColor"/></svg>',
|
|
91
|
+
blog_post: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4h11l3 3v13H5z"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="14" y2="15"/></svg>',
|
|
92
|
+
email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3,7 12,13 21,7"/></svg>',
|
|
93
|
+
module_only: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="6" width="16" height="4" rx="1"/><rect x="4" y="14" width="10" height="4" rx="1"/></svg>',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const PAGE_TYPE_LABEL_LONG = {
|
|
97
|
+
landing_page: "Landing page",
|
|
98
|
+
website_page: "Website page",
|
|
99
|
+
blog_post: "Blog post",
|
|
100
|
+
email: "Email",
|
|
101
|
+
module_only: "Section",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function renderPageTabs(templates, activeTemplateId) {
|
|
105
|
+
const list = document.getElementById("page-tabs-list");
|
|
106
|
+
if (!list) return;
|
|
107
|
+
|
|
108
|
+
currentTemplates = templates || [];
|
|
109
|
+
|
|
110
|
+
list.innerHTML = currentTemplates.map((t) => {
|
|
111
|
+
const isActive = t.id === activeTemplateId;
|
|
112
|
+
const badge = PAGE_TYPE_BADGES[t.pageType] || "";
|
|
113
|
+
const icon = PAGE_TYPE_ICONS[t.pageType] || PAGE_TYPE_ICONS.website_page;
|
|
114
|
+
const typeLabel = PAGE_TYPE_LABEL_LONG[t.pageType] || t.pageType;
|
|
115
|
+
const titleAttr = `${t.label} — ${typeLabel} (${t.moduleCount} modules)`;
|
|
116
|
+
return `<div class="page-tabs__row${isActive ? " page-tabs__row--active" : ""}" role="treeitem" aria-selected="${isActive ? "true" : "false"}" data-template-id="${t.id}" draggable="true" title="${escapeHtml(titleAttr)}">
|
|
117
|
+
<span class="page-tabs__handle" aria-hidden="true" title="Drag to reorder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg></span>
|
|
118
|
+
<button class="page-tabs__tab" type="button" data-template-id="${t.id}" data-action="activate">
|
|
119
|
+
<span class="page-tabs__icon" aria-hidden="true">${icon}</span>
|
|
120
|
+
${badge ? `<span class="page-tabs__tab-badge">${badge}</span>` : ""}
|
|
121
|
+
<span class="page-tabs__tab-label">${escapeHtml(t.label)}</span>
|
|
122
|
+
<span class="page-tabs__tab-count">${t.moduleCount}</span>
|
|
123
|
+
</button>
|
|
124
|
+
<button class="page-tabs__menu-btn" type="button" data-template-id="${t.id}" data-action="menu" title="Page actions" aria-label="Page actions" aria-haspopup="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="6" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="18" r="1"/></svg></button>
|
|
125
|
+
</div>`;
|
|
126
|
+
}).join("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function refreshPageTabs() {
|
|
130
|
+
return fetch("/api/templates")
|
|
131
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
132
|
+
.then((data) => {
|
|
133
|
+
if (!data) return;
|
|
134
|
+
currentTemplateId = data.activeTemplateId || currentTemplateId;
|
|
135
|
+
renderPageTabs(data.templates || [], currentTemplateId);
|
|
136
|
+
})
|
|
137
|
+
.catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handlePageTabClick(e) {
|
|
141
|
+
const menuBtn = e.target.closest(".page-tabs__menu-btn");
|
|
142
|
+
if (menuBtn) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
openPageContextMenu(menuBtn.dataset.templateId, menuBtn);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const tab = e.target.closest(".page-tabs__tab");
|
|
149
|
+
if (!tab) return;
|
|
150
|
+
const templateId = tab.dataset.templateId;
|
|
151
|
+
if (!templateId || templateId === currentTemplateId) return;
|
|
152
|
+
if (isStreaming) return;
|
|
153
|
+
|
|
154
|
+
tab.style.opacity = "0.5";
|
|
155
|
+
fetch("/api/templates/activate", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ templateId }),
|
|
159
|
+
}).then((res) => {
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
tab.style.opacity = "";
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
connectWebSocket();
|
|
165
|
+
}).catch(() => {
|
|
166
|
+
tab.style.opacity = "";
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Page tree context menu (right-click / kebab) — Edit, Duplicate, Delete, Move
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function handlePageTabContextMenu(e) {
|
|
175
|
+
const row = e.target.closest(".page-tabs__row");
|
|
176
|
+
if (!row) return;
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
openPageContextMenu(row.dataset.templateId, e);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function openPageContextMenu(templateId, anchor) {
|
|
182
|
+
closePageContextMenu();
|
|
183
|
+
if (!templateId) return;
|
|
184
|
+
const template = currentTemplates.find((t) => t.id === templateId);
|
|
185
|
+
if (!template) return;
|
|
186
|
+
|
|
187
|
+
const idx = currentTemplates.findIndex((t) => t.id === templateId);
|
|
188
|
+
const canMoveUp = idx > 0;
|
|
189
|
+
const canMoveDown = idx >= 0 && idx < currentTemplates.length - 1;
|
|
190
|
+
|
|
191
|
+
const menu = document.createElement("div");
|
|
192
|
+
menu.className = "page-tree__context-menu";
|
|
193
|
+
menu.id = "page-context-menu";
|
|
194
|
+
menu.setAttribute("role", "menu");
|
|
195
|
+
menu.innerHTML = [
|
|
196
|
+
`<button type="button" role="menuitem" data-action="edit">Edit name…</button>`,
|
|
197
|
+
`<button type="button" role="menuitem" data-action="duplicate">Duplicate</button>`,
|
|
198
|
+
`<div class="page-tree__context-sep" role="separator"></div>`,
|
|
199
|
+
`<button type="button" role="menuitem" data-action="move-up"${canMoveUp ? "" : " disabled"}>Move up</button>`,
|
|
200
|
+
`<button type="button" role="menuitem" data-action="move-down"${canMoveDown ? "" : " disabled"}>Move down</button>`,
|
|
201
|
+
`<div class="page-tree__context-sep" role="separator"></div>`,
|
|
202
|
+
`<button type="button" role="menuitem" data-action="delete" class="page-tree__context-menu-danger">Delete…</button>`,
|
|
203
|
+
].join("");
|
|
204
|
+
document.body.appendChild(menu);
|
|
205
|
+
|
|
206
|
+
// Position the menu. Anchor can be a MouseEvent or an HTMLElement.
|
|
207
|
+
let x = 0;
|
|
208
|
+
let y = 0;
|
|
209
|
+
if (anchor instanceof Event) {
|
|
210
|
+
x = anchor.clientX;
|
|
211
|
+
y = anchor.clientY;
|
|
212
|
+
} else if (anchor && anchor.getBoundingClientRect) {
|
|
213
|
+
const r = anchor.getBoundingClientRect();
|
|
214
|
+
x = r.right;
|
|
215
|
+
y = r.bottom;
|
|
216
|
+
}
|
|
217
|
+
// Constrain to viewport.
|
|
218
|
+
const menuRect = menu.getBoundingClientRect();
|
|
219
|
+
if (x + menuRect.width > window.innerWidth - 8) x = Math.max(8, window.innerWidth - menuRect.width - 8);
|
|
220
|
+
if (y + menuRect.height > window.innerHeight - 8) y = Math.max(8, window.innerHeight - menuRect.height - 8);
|
|
221
|
+
menu.style.left = `${x}px`;
|
|
222
|
+
menu.style.top = `${y}px`;
|
|
223
|
+
|
|
224
|
+
menu.addEventListener("click", (e) => {
|
|
225
|
+
const btn = e.target.closest("button[data-action]");
|
|
226
|
+
if (!btn || btn.disabled) return;
|
|
227
|
+
const action = btn.dataset.action;
|
|
228
|
+
closePageContextMenu();
|
|
229
|
+
runPageAction(templateId, action);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Dismiss on outside click / scroll / Escape.
|
|
233
|
+
setTimeout(() => {
|
|
234
|
+
document.addEventListener("click", closePageContextMenu, { once: true });
|
|
235
|
+
document.addEventListener("contextmenu", closePageContextMenu, { once: true });
|
|
236
|
+
window.addEventListener("scroll", closePageContextMenu, { capture: true, once: true });
|
|
237
|
+
}, 0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function closePageContextMenu() {
|
|
241
|
+
const menu = document.getElementById("page-context-menu");
|
|
242
|
+
if (menu) menu.remove();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
document.addEventListener("keydown", (e) => {
|
|
246
|
+
if (e.key === "Escape") closePageContextMenu();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
function runPageAction(templateId, action) {
|
|
250
|
+
if (isStreaming) return;
|
|
251
|
+
switch (action) {
|
|
252
|
+
case "edit": return renamePageAction(templateId);
|
|
253
|
+
case "duplicate": return duplicatePageAction(templateId);
|
|
254
|
+
case "delete": return deletePageAction(templateId);
|
|
255
|
+
case "move-up": return movePageAction(templateId, -1);
|
|
256
|
+
case "move-down": return movePageAction(templateId, 1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renamePageAction(templateId) {
|
|
261
|
+
const tpl = currentTemplates.find((t) => t.id === templateId);
|
|
262
|
+
if (!tpl) return;
|
|
263
|
+
// Inline rename: turn the label cell into a contenteditable.
|
|
264
|
+
const row = document.querySelector(`.page-tabs__row[data-template-id="${CSS.escape(templateId)}"]`);
|
|
265
|
+
const labelEl = row && row.querySelector(".page-tabs__tab-label");
|
|
266
|
+
if (!labelEl) {
|
|
267
|
+
// Fallback: native prompt.
|
|
268
|
+
const next = prompt("Rename page", tpl.label);
|
|
269
|
+
if (!next || next.trim() === tpl.label) return;
|
|
270
|
+
sendRename(templateId, next.trim());
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const oldLabel = tpl.label;
|
|
274
|
+
labelEl.contentEditable = "true";
|
|
275
|
+
labelEl.classList.add("page-tabs__tab-label--editing");
|
|
276
|
+
labelEl.focus();
|
|
277
|
+
const range = document.createRange();
|
|
278
|
+
range.selectNodeContents(labelEl);
|
|
279
|
+
const sel = window.getSelection();
|
|
280
|
+
sel.removeAllRanges();
|
|
281
|
+
sel.addRange(range);
|
|
282
|
+
|
|
283
|
+
let committed = false;
|
|
284
|
+
function commit() {
|
|
285
|
+
if (committed) return;
|
|
286
|
+
committed = true;
|
|
287
|
+
labelEl.contentEditable = "false";
|
|
288
|
+
labelEl.classList.remove("page-tabs__tab-label--editing");
|
|
289
|
+
const next = labelEl.textContent.trim();
|
|
290
|
+
if (!next || next === oldLabel) {
|
|
291
|
+
labelEl.textContent = oldLabel;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
sendRename(templateId, next);
|
|
295
|
+
}
|
|
296
|
+
labelEl.addEventListener("blur", commit, { once: true });
|
|
297
|
+
labelEl.addEventListener("keydown", function handler(e) {
|
|
298
|
+
if (e.key === "Enter") {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
labelEl.removeEventListener("keydown", handler);
|
|
301
|
+
commit();
|
|
302
|
+
} else if (e.key === "Escape") {
|
|
303
|
+
labelEl.removeEventListener("keydown", handler);
|
|
304
|
+
committed = true;
|
|
305
|
+
labelEl.textContent = oldLabel;
|
|
306
|
+
labelEl.contentEditable = "false";
|
|
307
|
+
labelEl.classList.remove("page-tabs__tab-label--editing");
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sendRename(templateId, newLabel) {
|
|
313
|
+
fetch("/api/templates/rename", {
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { "Content-Type": "application/json" },
|
|
316
|
+
body: JSON.stringify({ templateId, newLabel }),
|
|
317
|
+
}).then((r) => r.json()).then((data) => {
|
|
318
|
+
if (data && data.ok) refreshPageTabs();
|
|
319
|
+
}).catch(() => {});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function duplicatePageAction(templateId) {
|
|
323
|
+
const tpl = currentTemplates.find((t) => t.id === templateId);
|
|
324
|
+
if (!tpl) return;
|
|
325
|
+
const label = prompt("Name for the duplicated page:", `${tpl.label} (Copy)`);
|
|
326
|
+
if (!label || !label.trim()) return;
|
|
327
|
+
fetch("/api/templates/clone", {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: { "Content-Type": "application/json" },
|
|
330
|
+
body: JSON.stringify({ templateId, label: label.trim() }),
|
|
331
|
+
}).then((r) => r.json()).then((data) => {
|
|
332
|
+
if (data && data.ok && data.template && data.template.id) {
|
|
333
|
+
// Activate the clone so the user lands on it (mirrors createPage flow).
|
|
334
|
+
return fetch("/api/templates/activate", {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "Content-Type": "application/json" },
|
|
337
|
+
body: JSON.stringify({ templateId: data.template.id }),
|
|
338
|
+
}).then(() => { refreshPageTabs(); connectWebSocket(); });
|
|
339
|
+
}
|
|
340
|
+
}).catch(() => {});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function deletePageAction(templateId) {
|
|
344
|
+
const tpl = currentTemplates.find((t) => t.id === templateId);
|
|
345
|
+
if (!tpl) return;
|
|
346
|
+
const msg = `Delete "${tpl.label}"? This removes the page and its template file. Sections (modules) are kept in the library.`;
|
|
347
|
+
if (!confirm(msg)) return;
|
|
348
|
+
fetch("/api/templates", {
|
|
349
|
+
method: "DELETE",
|
|
350
|
+
headers: { "Content-Type": "application/json" },
|
|
351
|
+
body: JSON.stringify({ templateId, deleteModules: false }),
|
|
352
|
+
}).then((r) => r.json()).then((data) => {
|
|
353
|
+
if (data && data.ok) {
|
|
354
|
+
refreshPageTabs();
|
|
355
|
+
// If we deleted the active page, the server picks a new active — reconnect.
|
|
356
|
+
if (templateId === currentTemplateId) connectWebSocket();
|
|
357
|
+
}
|
|
358
|
+
}).catch(() => {});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function movePageAction(templateId, delta) {
|
|
362
|
+
const ids = currentTemplates.map((t) => t.id);
|
|
363
|
+
const idx = ids.indexOf(templateId);
|
|
364
|
+
if (idx < 0) return;
|
|
365
|
+
const target = idx + delta;
|
|
366
|
+
if (target < 0 || target >= ids.length) return;
|
|
367
|
+
ids.splice(idx, 1);
|
|
368
|
+
ids.splice(target, 0, templateId);
|
|
369
|
+
sendReorder(ids);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function sendReorder(templateIds) {
|
|
373
|
+
// Optimistic re-render so the move feels instant.
|
|
374
|
+
const map = new Map(currentTemplates.map((t) => [t.id, t]));
|
|
375
|
+
const next = templateIds.map((id) => map.get(id)).filter(Boolean);
|
|
376
|
+
if (next.length === currentTemplates.length) {
|
|
377
|
+
renderPageTabs(next, currentTemplateId);
|
|
378
|
+
}
|
|
379
|
+
fetch("/api/templates/reorder", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { "Content-Type": "application/json" },
|
|
382
|
+
body: JSON.stringify({ templateIds }),
|
|
383
|
+
}).then((r) => r.json()).then((data) => {
|
|
384
|
+
if (!data || !data.ok) refreshPageTabs();
|
|
385
|
+
}).catch(() => refreshPageTabs());
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Drag and drop reordering (HTML5)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
function handlePageTabDragStart(e) {
|
|
393
|
+
const row = e.target.closest(".page-tabs__row");
|
|
394
|
+
if (!row) return;
|
|
395
|
+
pageDragSourceId = row.dataset.templateId || null;
|
|
396
|
+
row.classList.add("page-tabs__row--dragging");
|
|
397
|
+
if (e.dataTransfer) {
|
|
398
|
+
e.dataTransfer.effectAllowed = "move";
|
|
399
|
+
// Some browsers need data set to start the drag.
|
|
400
|
+
try { e.dataTransfer.setData("text/plain", pageDragSourceId || ""); } catch (_) {}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function handlePageTabDragEnd(e) {
|
|
405
|
+
const row = e.target.closest(".page-tabs__row");
|
|
406
|
+
if (row) row.classList.remove("page-tabs__row--dragging");
|
|
407
|
+
pageDragSourceId = null;
|
|
408
|
+
document.querySelectorAll(".page-tabs__row--drop-before, .page-tabs__row--drop-after")
|
|
409
|
+
.forEach((el) => el.classList.remove("page-tabs__row--drop-before", "page-tabs__row--drop-after"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function handlePageTabDragOver(e) {
|
|
413
|
+
if (!pageDragSourceId) return;
|
|
414
|
+
const row = e.target.closest(".page-tabs__row");
|
|
415
|
+
if (!row || row.dataset.templateId === pageDragSourceId) return;
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
418
|
+
const rect = row.getBoundingClientRect();
|
|
419
|
+
const before = e.clientY - rect.top < rect.height / 2;
|
|
420
|
+
document.querySelectorAll(".page-tabs__row--drop-before, .page-tabs__row--drop-after")
|
|
421
|
+
.forEach((el) => el.classList.remove("page-tabs__row--drop-before", "page-tabs__row--drop-after"));
|
|
422
|
+
row.classList.add(before ? "page-tabs__row--drop-before" : "page-tabs__row--drop-after");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function handlePageTabDrop(e) {
|
|
426
|
+
if (!pageDragSourceId) return;
|
|
427
|
+
const row = e.target.closest(".page-tabs__row");
|
|
428
|
+
if (!row) return;
|
|
429
|
+
const targetId = row.dataset.templateId;
|
|
430
|
+
if (!targetId || targetId === pageDragSourceId) return;
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
const ids = currentTemplates.map((t) => t.id);
|
|
433
|
+
const from = ids.indexOf(pageDragSourceId);
|
|
434
|
+
const to = ids.indexOf(targetId);
|
|
435
|
+
if (from < 0 || to < 0) return;
|
|
436
|
+
const rect = row.getBoundingClientRect();
|
|
437
|
+
const before = e.clientY - rect.top < rect.height / 2;
|
|
438
|
+
ids.splice(from, 1);
|
|
439
|
+
// After removing the source, the target index may have shifted by one.
|
|
440
|
+
const adjustedTo = to > from ? to - 1 : to;
|
|
441
|
+
const insertAt = before ? adjustedTo : adjustedTo + 1;
|
|
442
|
+
ids.splice(insertAt, 0, pageDragSourceId);
|
|
443
|
+
sendReorder(ids);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function handleAddPage() {
|
|
447
|
+
if (isStreaming) return;
|
|
448
|
+
const inlineForm = document.getElementById("page-tree-add-inline");
|
|
449
|
+
if (inlineForm) {
|
|
450
|
+
inlineForm.classList.toggle("hidden");
|
|
451
|
+
if (!inlineForm.classList.contains("hidden")) {
|
|
452
|
+
document.getElementById("page-tree-name")?.focus();
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const label = prompt("Page name:");
|
|
457
|
+
if (!label || !label.trim()) return;
|
|
458
|
+
createPage("website_page", label.trim());
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleInlineCreate() {
|
|
462
|
+
if (isStreaming) return;
|
|
463
|
+
const nameInput = document.getElementById("page-tree-name");
|
|
464
|
+
const typeSelect = document.getElementById("page-tree-type");
|
|
465
|
+
const label = nameInput?.value?.trim();
|
|
466
|
+
if (!label) { nameInput?.focus(); return; }
|
|
467
|
+
const pageType = typeSelect?.value || "website_page";
|
|
468
|
+
createPage(pageType, label);
|
|
469
|
+
nameInput.value = "";
|
|
470
|
+
document.getElementById("page-tree-add-inline")?.classList.add("hidden");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function createPage(pageType, label) {
|
|
474
|
+
fetch("/api/templates", {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: { "Content-Type": "application/json" },
|
|
477
|
+
body: JSON.stringify({ pageType, label }),
|
|
478
|
+
}).then((res) => {
|
|
479
|
+
if (!res.ok) return null;
|
|
480
|
+
return res.json();
|
|
481
|
+
}).then((data) => {
|
|
482
|
+
const newId = data && data.template && data.template.id;
|
|
483
|
+
if (!newId) return;
|
|
484
|
+
// Server's addTemplate already marks the new template as active, but we
|
|
485
|
+
// call activate to sync the flat-field cache and let the WS reconnect
|
|
486
|
+
// re-broadcast the full session (modules + templates + active id).
|
|
487
|
+
return fetch("/api/templates/activate", {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers: { "Content-Type": "application/json" },
|
|
490
|
+
body: JSON.stringify({ templateId: newId }),
|
|
491
|
+
}).then((res) => {
|
|
492
|
+
if (!res || !res.ok) return;
|
|
493
|
+
// Re-render the tree immediately from the canonical list so the new
|
|
494
|
+
// page shows up even before the WS init message arrives.
|
|
495
|
+
refreshPageTabs();
|
|
496
|
+
connectWebSocket();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
(function initPageTree() {
|
|
502
|
+
const list = document.getElementById("page-tabs-list");
|
|
503
|
+
const addBtn = document.getElementById("btn-add-page");
|
|
504
|
+
const createBtn = document.getElementById("page-tree-create");
|
|
505
|
+
const nameInput = document.getElementById("page-tree-name");
|
|
506
|
+
if (list) {
|
|
507
|
+
list.addEventListener("click", handlePageTabClick);
|
|
508
|
+
list.addEventListener("contextmenu", handlePageTabContextMenu);
|
|
509
|
+
list.addEventListener("dragstart", handlePageTabDragStart);
|
|
510
|
+
list.addEventListener("dragend", handlePageTabDragEnd);
|
|
511
|
+
list.addEventListener("dragover", handlePageTabDragOver);
|
|
512
|
+
list.addEventListener("drop", handlePageTabDrop);
|
|
513
|
+
}
|
|
514
|
+
if (addBtn) addBtn.addEventListener("click", handleAddPage);
|
|
515
|
+
if (createBtn) createBtn.addEventListener("click", handleInlineCreate);
|
|
516
|
+
if (nameInput) nameInput.addEventListener("keydown", (e) => {
|
|
517
|
+
if (e.key === "Enter") handleInlineCreate();
|
|
518
|
+
if (e.key === "Escape") document.getElementById("page-tree-add-inline")?.classList.add("hidden");
|
|
519
|
+
});
|
|
520
|
+
})();
|
|
26
521
|
|
|
27
522
|
// ---------------------------------------------------------------------------
|
|
28
523
|
// WebSocket connection
|
|
@@ -77,18 +572,32 @@ function handleWsMessage(msg) {
|
|
|
77
572
|
currentSessionId = msg.sessionId || "";
|
|
78
573
|
currentTemplateId = msg.templateId || "";
|
|
79
574
|
document.getElementById("theme-name").textContent = msg.themeName || "—";
|
|
575
|
+
setStatusTheme(msg.themeName);
|
|
576
|
+
lastSavedAt = typeof msg.updatedAt === "number" ? msg.updatedAt : 0;
|
|
577
|
+
renderLastSaved();
|
|
578
|
+
if (msg.engine) statusEngine.textContent = msg.engine;
|
|
80
579
|
|
|
81
580
|
// Clear previous project's chat and module list
|
|
82
581
|
messagesEl.innerHTML = "";
|
|
83
|
-
document.getElementById("module-items").innerHTML = "";
|
|
84
|
-
document.getElementById("module-count").textContent = "0";
|
|
582
|
+
const _mi = document.getElementById("module-items"); if (_mi) _mi.innerHTML = "";
|
|
583
|
+
const _mc = document.getElementById("module-count") || document.getElementById("slideout-module-count"); if (_mc) _mc.textContent = "0";
|
|
584
|
+
hideChatSuggestions();
|
|
585
|
+
// Reset pipeline state — DOM nodes were detached by innerHTML clear
|
|
586
|
+
resetPipelineState();
|
|
587
|
+
stopPipelineTimer();
|
|
588
|
+
streamingMsgEl = null;
|
|
85
589
|
|
|
86
590
|
if (msg.modules && msg.modules.length > 0) {
|
|
87
591
|
updateModuleList(msg.modules);
|
|
88
592
|
refreshPreview();
|
|
89
593
|
}
|
|
594
|
+
|
|
595
|
+
// Render page tabs for multi-page projects
|
|
596
|
+
renderPageTabs(msg.templates, currentTemplateId);
|
|
597
|
+
|
|
90
598
|
statusEngine.textContent = msg.engine || "";
|
|
91
599
|
fetchHsAccountStatus();
|
|
600
|
+
if (typeof refreshPortalIndicator === "function") refreshPortalIndicator();
|
|
92
601
|
|
|
93
602
|
// Populate chat header
|
|
94
603
|
const chatHeaderTitle = document.getElementById("chat-header-title");
|
|
@@ -106,6 +615,8 @@ function handleWsMessage(msg) {
|
|
|
106
615
|
}
|
|
107
616
|
}
|
|
108
617
|
scrollToBottom();
|
|
618
|
+
} else {
|
|
619
|
+
restoreWelcome();
|
|
109
620
|
}
|
|
110
621
|
|
|
111
622
|
// Show/hide version history button
|
|
@@ -114,10 +625,47 @@ function handleWsMessage(msg) {
|
|
|
114
625
|
historyBtn.style.display = msg.gitAvailable ? "" : "none";
|
|
115
626
|
}
|
|
116
627
|
|
|
628
|
+
// Initialize history timeline (compact strip above chat input)
|
|
629
|
+
historyGitAvailable = !!msg.gitAvailable;
|
|
630
|
+
if (historyGitAvailable) {
|
|
631
|
+
historyTimelineCursor = 0;
|
|
632
|
+
refreshHistoryTimeline();
|
|
633
|
+
} else {
|
|
634
|
+
const tl = document.getElementById("history-timeline");
|
|
635
|
+
if (tl) tl.classList.add("hidden");
|
|
636
|
+
}
|
|
637
|
+
|
|
117
638
|
// Hydrate plan-mode state (toggle + Plan pane content)
|
|
118
639
|
if (window.planController) {
|
|
119
640
|
window.planController.setInitialState(msg);
|
|
120
641
|
}
|
|
642
|
+
|
|
643
|
+
// If a pipeline is actively running (reconnect scenario), lock the
|
|
644
|
+
// input so the user can't double-submit. Replayed pipeline events
|
|
645
|
+
// that follow this init message will rebuild the progress UI.
|
|
646
|
+
if (msg.isGenerating) {
|
|
647
|
+
isStreaming = true;
|
|
648
|
+
sendBtn.disabled = true;
|
|
649
|
+
streamStartTime = Date.now();
|
|
650
|
+
if (typeof window.setSelectModeDisabled === "function") {
|
|
651
|
+
window.setSelectModeDisabled(true);
|
|
652
|
+
}
|
|
653
|
+
if (typeof window.setEditModeDisabled === "function") {
|
|
654
|
+
window.setEditModeDisabled(true);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// If setup handed us an initial prompt (describe-it path), send it now
|
|
659
|
+
// that the session is live. Skip if the project already has history
|
|
660
|
+
// (e.g. resumed session) to avoid double-submitting.
|
|
661
|
+
if (window.__pendingInitialPrompt) {
|
|
662
|
+
const pendingPrompt = window.__pendingInitialPrompt;
|
|
663
|
+
window.__pendingInitialPrompt = null;
|
|
664
|
+
const hasHistory = msg.messages && msg.messages.length > 0;
|
|
665
|
+
if (!hasHistory) {
|
|
666
|
+
setTimeout(() => sendMessage(pendingPrompt), 50);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
121
669
|
break;
|
|
122
670
|
|
|
123
671
|
case "stream":
|
|
@@ -131,17 +679,36 @@ function handleWsMessage(msg) {
|
|
|
131
679
|
case "generation_complete":
|
|
132
680
|
clearStreamStatus();
|
|
133
681
|
finishStreaming();
|
|
682
|
+
// The next `modules_updated` is the terminal one for this run — let it
|
|
683
|
+
// fire the change-highlight pass on the preview iframe.
|
|
684
|
+
highlightOnNextModulesUpdated = true;
|
|
685
|
+
// Refresh the Library tab's module list so it stays in sync
|
|
686
|
+
if (typeof refreshDashboard === "function") refreshDashboard();
|
|
134
687
|
break;
|
|
135
688
|
|
|
136
689
|
case "modules_updated":
|
|
137
690
|
if (msg.modules) {
|
|
138
691
|
updateModuleList(msg.modules);
|
|
139
692
|
}
|
|
140
|
-
|
|
693
|
+
if (msg.templates) {
|
|
694
|
+
if (msg.templateId) currentTemplateId = msg.templateId;
|
|
695
|
+
renderPageTabs(msg.templates, currentTemplateId);
|
|
696
|
+
}
|
|
697
|
+
if (highlightOnNextModulesUpdated) {
|
|
698
|
+
highlightOnNextModulesUpdated = false;
|
|
699
|
+
flushChangeHighlights(msg.modules || []);
|
|
700
|
+
} else {
|
|
701
|
+
refreshPreview();
|
|
702
|
+
}
|
|
703
|
+
markSaved(msg.updatedAt);
|
|
141
704
|
break;
|
|
142
705
|
|
|
143
706
|
case "version_created":
|
|
144
707
|
if (historyPanelOpen) refreshHistoryPanel();
|
|
708
|
+
// New generation: reset timeline cursor to head and refresh.
|
|
709
|
+
historyTimelineCursor = 0;
|
|
710
|
+
refreshHistoryTimeline();
|
|
711
|
+
markSaved(msg.updatedAt);
|
|
145
712
|
break;
|
|
146
713
|
|
|
147
714
|
case "parse_warning":
|
|
@@ -150,11 +717,10 @@ function handleWsMessage(msg) {
|
|
|
150
717
|
|
|
151
718
|
case "error":
|
|
152
719
|
stopPipelineTimer();
|
|
720
|
+
stopPipelineEstimate();
|
|
153
721
|
if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
|
|
154
722
|
finishStreaming();
|
|
155
|
-
|
|
156
|
-
pipelineStepsEl = null;
|
|
157
|
-
pipelineModulesEl = null;
|
|
723
|
+
resetPipelineState();
|
|
158
724
|
appendAssistantError(msg.message);
|
|
159
725
|
setStatus("Error");
|
|
160
726
|
break;
|
|
@@ -202,8 +768,8 @@ function handleWsMessage(msg) {
|
|
|
202
768
|
case "needs_setup":
|
|
203
769
|
// Clear stale UI if shown
|
|
204
770
|
messagesEl.innerHTML = "";
|
|
205
|
-
document.getElementById("module-items").innerHTML = "";
|
|
206
|
-
document.getElementById("module-count").textContent = "0";
|
|
771
|
+
{ const _mi = document.getElementById("module-items"); if (_mi) _mi.innerHTML = ""; }
|
|
772
|
+
{ const _mc = document.getElementById("module-count") || document.getElementById("slideout-module-count"); if (_mc) _mc.textContent = "0"; }
|
|
207
773
|
break;
|
|
208
774
|
|
|
209
775
|
case "plan_updated":
|
|
@@ -246,14 +812,73 @@ function handleWsMessage(msg) {
|
|
|
246
812
|
const STEP_LABELS = {
|
|
247
813
|
analyzing: "Analyzing",
|
|
248
814
|
designing: "Designing",
|
|
249
|
-
developing: "
|
|
815
|
+
developing: "Building",
|
|
250
816
|
quality_check: "Quality Check",
|
|
251
817
|
};
|
|
252
818
|
const STEP_ORDER = ["analyzing", "designing", "developing", "quality_check"];
|
|
253
819
|
|
|
820
|
+
const STEP_ICONS = {
|
|
821
|
+
analyzing:
|
|
822
|
+
'<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>',
|
|
823
|
+
designing:
|
|
824
|
+
'<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>',
|
|
825
|
+
developing:
|
|
826
|
+
'<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>',
|
|
827
|
+
quality_check:
|
|
828
|
+
'<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>',
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const CHECK_ICON =
|
|
832
|
+
'<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>';
|
|
833
|
+
|
|
834
|
+
// Per-module wall-clock seconds. Rough heuristic for time-remaining display.
|
|
835
|
+
const ESTIMATED_SECONDS_PER_MODULE = 14;
|
|
836
|
+
|
|
254
837
|
let pipelineBubbleEl = null;
|
|
255
|
-
let
|
|
838
|
+
let pipelineStepperEl = null;
|
|
839
|
+
let pipelineDetailEl = null;
|
|
256
840
|
let pipelineModulesEl = null;
|
|
841
|
+
let pipelineEstimateEl = null;
|
|
842
|
+
let pipelineCurrentStep = null;
|
|
843
|
+
let pipelineEstimateInterval = null;
|
|
844
|
+
|
|
845
|
+
function buildStepperHtml() {
|
|
846
|
+
const parts = [];
|
|
847
|
+
for (let i = 0; i < STEP_ORDER.length; i++) {
|
|
848
|
+
const step = STEP_ORDER[i];
|
|
849
|
+
parts.push(`
|
|
850
|
+
<div class="pipeline-stage pipeline-stage--pending" data-stage="${step}">
|
|
851
|
+
<div class="pipeline-stage__indicator">
|
|
852
|
+
<span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
|
|
853
|
+
<span class="pipeline-stage__check">${CHECK_ICON}</span>
|
|
854
|
+
</div>
|
|
855
|
+
<div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
|
|
856
|
+
</div>`);
|
|
857
|
+
if (i < STEP_ORDER.length - 1) {
|
|
858
|
+
parts.push('<div class="pipeline-stage__connector" data-after="' + step + '"></div>');
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return `<div class="pipeline-stepper" role="progressbar" aria-label="Generation progress">${parts.join("")}</div>`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function buildPipelineBodyHtml() {
|
|
865
|
+
return `
|
|
866
|
+
${buildStepperHtml()}
|
|
867
|
+
<div class="pipeline-detail" hidden></div>
|
|
868
|
+
<div class="pipeline-modules"></div>
|
|
869
|
+
<div class="pipeline-footer">
|
|
870
|
+
<div class="pipeline-timer"></div>
|
|
871
|
+
<div class="pipeline-estimate" hidden></div>
|
|
872
|
+
</div>`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function captureBubbleRefs(bubbleEl, container) {
|
|
876
|
+
pipelineBubbleEl = container;
|
|
877
|
+
pipelineStepperEl = bubbleEl.querySelector(".pipeline-stepper");
|
|
878
|
+
pipelineDetailEl = bubbleEl.querySelector(".pipeline-detail");
|
|
879
|
+
pipelineModulesEl = bubbleEl.querySelector(".pipeline-modules");
|
|
880
|
+
pipelineEstimateEl = bubbleEl.querySelector(".pipeline-estimate");
|
|
881
|
+
}
|
|
257
882
|
|
|
258
883
|
function ensurePipelineBubble() {
|
|
259
884
|
if (pipelineBubbleEl) return;
|
|
@@ -270,13 +895,8 @@ function ensurePipelineBubble() {
|
|
|
270
895
|
if (streamingMsgEl) {
|
|
271
896
|
const existingDiv = streamingMsgEl.closest(".chat-msg");
|
|
272
897
|
if (existingDiv) {
|
|
273
|
-
streamingMsgEl.innerHTML =
|
|
274
|
-
|
|
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");
|
|
898
|
+
streamingMsgEl.innerHTML = buildPipelineBodyHtml();
|
|
899
|
+
captureBubbleRefs(streamingMsgEl, existingDiv);
|
|
280
900
|
startPipelineTimer(streamingMsgEl.querySelector(".pipeline-timer"));
|
|
281
901
|
scrollToBottom();
|
|
282
902
|
return;
|
|
@@ -294,64 +914,80 @@ function ensurePipelineBubble() {
|
|
|
294
914
|
<span class="chat-msg__time">${time}</span>
|
|
295
915
|
</div>
|
|
296
916
|
<div class="chat-msg__bubble">
|
|
297
|
-
|
|
298
|
-
<div class="pipeline-modules"></div>
|
|
299
|
-
<div class="pipeline-timer"></div>
|
|
917
|
+
${buildPipelineBodyHtml()}
|
|
300
918
|
</div>
|
|
301
919
|
</div>`;
|
|
302
920
|
messagesEl.appendChild(div);
|
|
303
921
|
|
|
304
|
-
pipelineBubbleEl = div;
|
|
305
|
-
pipelineStepsEl = div.querySelector(".pipeline-steps");
|
|
306
|
-
pipelineModulesEl = div.querySelector(".pipeline-modules");
|
|
307
922
|
streamingMsgEl = div.querySelector(".chat-msg__bubble");
|
|
923
|
+
captureBubbleRefs(streamingMsgEl, div);
|
|
308
924
|
startPipelineTimer(div.querySelector(".pipeline-timer"));
|
|
309
925
|
scrollToBottom();
|
|
310
926
|
}
|
|
311
927
|
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
928
|
+
function setStageStatus(step, status) {
|
|
929
|
+
if (!pipelineStepperEl) return;
|
|
930
|
+
const stage = pipelineStepperEl.querySelector(`[data-stage="${step}"]`);
|
|
931
|
+
if (!stage) return;
|
|
932
|
+
stage.classList.remove(
|
|
933
|
+
"pipeline-stage--pending",
|
|
934
|
+
"pipeline-stage--active",
|
|
935
|
+
"pipeline-stage--done",
|
|
936
|
+
"pipeline-stage--failed",
|
|
937
|
+
);
|
|
938
|
+
stage.classList.add("pipeline-stage--" + status);
|
|
939
|
+
|
|
940
|
+
// Fill the connector behind this stage when it becomes active or done.
|
|
941
|
+
const idx = STEP_ORDER.indexOf(step);
|
|
942
|
+
if (idx > 0) {
|
|
943
|
+
const prev = STEP_ORDER[idx - 1];
|
|
944
|
+
const conn = pipelineStepperEl.querySelector(`[data-after="${prev}"]`);
|
|
945
|
+
if (conn) conn.classList.add("pipeline-stage__connector--filled");
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function setPipelineDetail(text) {
|
|
950
|
+
if (!pipelineDetailEl) return;
|
|
951
|
+
if (!text) {
|
|
952
|
+
pipelineDetailEl.hidden = true;
|
|
953
|
+
pipelineDetailEl.textContent = "";
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
pipelineDetailEl.hidden = false;
|
|
957
|
+
pipelineDetailEl.classList.remove("pipeline-detail--in");
|
|
958
|
+
// Force reflow so the animation restarts when text changes
|
|
959
|
+
void pipelineDetailEl.offsetWidth;
|
|
960
|
+
pipelineDetailEl.classList.add("pipeline-detail--in");
|
|
961
|
+
pipelineDetailEl.textContent = text;
|
|
317
962
|
}
|
|
318
963
|
|
|
319
964
|
function handleAgentStep(msg) {
|
|
320
965
|
ensurePipelineBubble();
|
|
321
966
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
existing.forEach((el) => {
|
|
332
|
-
if (!el.classList.contains("pipeline-step--done")) {
|
|
333
|
-
markStepDone(el);
|
|
967
|
+
const incoming = msg.step;
|
|
968
|
+
const incomingIdx = STEP_ORDER.indexOf(incoming);
|
|
969
|
+
|
|
970
|
+
// Mark all prior stages done (handles forward jumps + repeated firings)
|
|
971
|
+
if (incomingIdx >= 0) {
|
|
972
|
+
for (let i = 0; i < incomingIdx; i++) {
|
|
973
|
+
const prevStage = pipelineStepperEl.querySelector(`[data-stage="${STEP_ORDER[i]}"]`);
|
|
974
|
+
if (prevStage && !prevStage.classList.contains("pipeline-stage--done")) {
|
|
975
|
+
setStageStatus(STEP_ORDER[i], "done");
|
|
334
976
|
}
|
|
335
|
-
}
|
|
977
|
+
}
|
|
336
978
|
}
|
|
337
979
|
|
|
338
|
-
|
|
339
|
-
|
|
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>`;
|
|
980
|
+
setStageStatus(incoming, "active");
|
|
981
|
+
pipelineCurrentStep = incoming;
|
|
343
982
|
|
|
344
|
-
|
|
345
|
-
// developing → module cards → quality check
|
|
346
|
-
if (msg.step === "quality_check" && pipelineModulesEl) {
|
|
347
|
-
pipelineModulesEl.after(step);
|
|
348
|
-
} else {
|
|
349
|
-
pipelineStepsEl.appendChild(step);
|
|
350
|
-
}
|
|
983
|
+
setPipelineDetail(msg.label || STEP_LABELS[incoming] || incoming);
|
|
351
984
|
|
|
352
|
-
|
|
353
|
-
if (msg.step === "developing") {
|
|
985
|
+
if (incoming === "developing") {
|
|
354
986
|
pipelineModulesEl.innerHTML = "";
|
|
987
|
+
startPipelineEstimate();
|
|
988
|
+
} else {
|
|
989
|
+
stopPipelineEstimate();
|
|
990
|
+
if (pipelineEstimateEl) pipelineEstimateEl.hidden = true;
|
|
355
991
|
}
|
|
356
992
|
|
|
357
993
|
scrollToBottom();
|
|
@@ -359,17 +995,7 @@ function handleAgentStep(msg) {
|
|
|
359
995
|
|
|
360
996
|
function handleAgentDecision(msg) {
|
|
361
997
|
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
|
-
}
|
|
998
|
+
setPipelineDetail(msg.decision);
|
|
373
999
|
scrollToBottom();
|
|
374
1000
|
}
|
|
375
1001
|
|
|
@@ -379,9 +1005,18 @@ function handleModuleProgress(msg) {
|
|
|
379
1005
|
let card = pipelineModulesEl.querySelector(`[data-module="${CSS.escape(msg.module)}"]`);
|
|
380
1006
|
if (!card) {
|
|
381
1007
|
card = document.createElement("div");
|
|
382
|
-
card.className = "pipeline-module-card";
|
|
383
1008
|
card.dataset.module = msg.module;
|
|
384
|
-
card.innerHTML =
|
|
1009
|
+
card.innerHTML = `
|
|
1010
|
+
<div class="pipeline-module-card__head">
|
|
1011
|
+
<span class="pipeline-module-card__name">${escapeHtml(msg.module)}</span>
|
|
1012
|
+
<span class="pipeline-module-card__status"></span>
|
|
1013
|
+
</div>
|
|
1014
|
+
<div class="pipeline-module-card__progress"><div class="pipeline-module-card__progress-bar"></div></div>
|
|
1015
|
+
<div class="pipeline-module-card__skeleton">
|
|
1016
|
+
<div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--lg"></div>
|
|
1017
|
+
<div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--md"></div>
|
|
1018
|
+
<div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--sm"></div>
|
|
1019
|
+
</div>`;
|
|
385
1020
|
pipelineModulesEl.appendChild(card);
|
|
386
1021
|
}
|
|
387
1022
|
|
|
@@ -390,52 +1025,242 @@ function handleModuleProgress(msg) {
|
|
|
390
1025
|
|
|
391
1026
|
const statusLabels = {
|
|
392
1027
|
queued: "queued",
|
|
393
|
-
generating: "generating
|
|
394
|
-
validating: "validating
|
|
395
|
-
retrying: "retrying
|
|
396
|
-
complete: "
|
|
397
|
-
failed: "
|
|
1028
|
+
generating: "generating",
|
|
1029
|
+
validating: "validating",
|
|
1030
|
+
retrying: "retrying",
|
|
1031
|
+
complete: "done",
|
|
1032
|
+
failed: "failed",
|
|
398
1033
|
};
|
|
399
|
-
statusEl.textContent = statusLabels[msg.status] || msg.status;
|
|
1034
|
+
if (statusEl) statusEl.textContent = statusLabels[msg.status] || msg.status;
|
|
400
1035
|
|
|
401
|
-
// Mark/clear working overlay in the preview
|
|
402
1036
|
if (msg.status === "generating" && typeof markModulesWorking === "function") {
|
|
403
1037
|
markModulesWorking([msg.module]);
|
|
404
1038
|
} else if ((msg.status === "complete" || msg.status === "failed") && typeof clearModuleWorking === "function") {
|
|
405
1039
|
clearModuleWorking(msg.module);
|
|
406
1040
|
}
|
|
407
1041
|
|
|
1042
|
+
updatePipelineEstimate();
|
|
1043
|
+
|
|
1044
|
+
if (msg.status === "complete" && msg.module) {
|
|
1045
|
+
changedModulesInRun.add(msg.module);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Render code snippet preview when a module finishes generating, and update
|
|
1049
|
+
// the global "Valid HubL" indicator based on a quick syntax check.
|
|
1050
|
+
if (msg.status === "complete" && msg.moduleFiles) {
|
|
1051
|
+
appendModuleCardSnippet(card, msg.module, msg.moduleFiles);
|
|
1052
|
+
if (typeof window.reportHublCheck === "function") {
|
|
1053
|
+
const issues = quickCheckHubl(msg.moduleFiles);
|
|
1054
|
+
window.reportHublCheck(msg.module, issues);
|
|
1055
|
+
}
|
|
1056
|
+
} else if (msg.status === "failed" && typeof window.reportHublCheck === "function") {
|
|
1057
|
+
window.reportHublCheck(msg.module, [{ kind: "generation_failed", message: "Module generation failed" }]);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
408
1060
|
scrollToBottom();
|
|
409
1061
|
}
|
|
410
1062
|
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
// Code snippet preview inside a pipeline module card
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
const HUBL_PREVIEW_MAX_LINES = 12;
|
|
1068
|
+
const HUBL_PREVIEW_MAX_CHARS = 800;
|
|
1069
|
+
|
|
1070
|
+
function appendModuleCardSnippet(card, moduleName, files) {
|
|
1071
|
+
if (card.querySelector(".pipeline-module-card__snippet")) return;
|
|
1072
|
+
const snippet = buildHublSnippet(files.moduleHtml || "");
|
|
1073
|
+
if (!snippet) return;
|
|
1074
|
+
|
|
1075
|
+
const wrap = document.createElement("div");
|
|
1076
|
+
wrap.className = "pipeline-module-card__snippet";
|
|
1077
|
+
|
|
1078
|
+
const header = document.createElement("div");
|
|
1079
|
+
header.className = "pipeline-module-card__snippet-head";
|
|
1080
|
+
header.innerHTML = `
|
|
1081
|
+
<span class="pipeline-module-card__snippet-label">module.html</span>
|
|
1082
|
+
<button class="pipeline-module-card__snippet-toggle" type="button" aria-expanded="false">
|
|
1083
|
+
<svg class="icon icon--sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
|
1084
|
+
<span class="pipeline-module-card__snippet-toggle-label">Show code</span>
|
|
1085
|
+
</button>`;
|
|
1086
|
+
|
|
1087
|
+
const codeWrap = document.createElement("div");
|
|
1088
|
+
codeWrap.className = "pipeline-module-card__snippet-code hidden";
|
|
1089
|
+
const pre = document.createElement("pre");
|
|
1090
|
+
const codeEl = document.createElement("code");
|
|
1091
|
+
codeEl.className = "language-hubl";
|
|
1092
|
+
codeEl.textContent = snippet.text;
|
|
1093
|
+
pre.appendChild(codeEl);
|
|
1094
|
+
codeWrap.appendChild(pre);
|
|
1095
|
+
|
|
1096
|
+
if (snippet.truncated) {
|
|
1097
|
+
const more = document.createElement("button");
|
|
1098
|
+
more.className = "pipeline-module-card__snippet-open";
|
|
1099
|
+
more.type = "button";
|
|
1100
|
+
more.textContent = "Open in code view →";
|
|
1101
|
+
more.addEventListener("click", () => openCodeViewForModule(moduleName));
|
|
1102
|
+
codeWrap.appendChild(more);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
header.querySelector("button").addEventListener("click", () => {
|
|
1106
|
+
const expanded = !codeWrap.classList.contains("hidden");
|
|
1107
|
+
codeWrap.classList.toggle("hidden", expanded);
|
|
1108
|
+
const toggle = header.querySelector(".pipeline-module-card__snippet-toggle");
|
|
1109
|
+
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
|
|
1110
|
+
const lbl = toggle.querySelector(".pipeline-module-card__snippet-toggle-label");
|
|
1111
|
+
if (lbl) lbl.textContent = expanded ? "Show code" : "Hide code";
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
wrap.appendChild(header);
|
|
1115
|
+
wrap.appendChild(codeWrap);
|
|
1116
|
+
card.appendChild(wrap);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function buildHublSnippet(html) {
|
|
1120
|
+
if (!html) return null;
|
|
1121
|
+
const trimmed = html.replace(/^\n+/, "").replace(/\n+$/, "");
|
|
1122
|
+
const lines = trimmed.split("\n");
|
|
1123
|
+
let out = lines.slice(0, HUBL_PREVIEW_MAX_LINES).join("\n");
|
|
1124
|
+
let truncated = lines.length > HUBL_PREVIEW_MAX_LINES;
|
|
1125
|
+
if (out.length > HUBL_PREVIEW_MAX_CHARS) {
|
|
1126
|
+
out = out.slice(0, HUBL_PREVIEW_MAX_CHARS);
|
|
1127
|
+
truncated = true;
|
|
1128
|
+
}
|
|
1129
|
+
return { text: out, truncated };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function openCodeViewForModule(moduleName) {
|
|
1133
|
+
const codeBtn = document.querySelector('.view-toggle__btn[data-view="code"]');
|
|
1134
|
+
if (codeBtn) codeBtn.click();
|
|
1135
|
+
// Best-effort selection of the module's html file if the file list is ready.
|
|
1136
|
+
setTimeout(() => {
|
|
1137
|
+
const list = document.getElementById("code-file-list");
|
|
1138
|
+
if (!list) return;
|
|
1139
|
+
const target = Array.from(list.querySelectorAll("[data-file-path]")).find((el) => {
|
|
1140
|
+
const p = el.getAttribute("data-file-path") || "";
|
|
1141
|
+
return p.includes(`/${moduleName}.module/module.html`) || p.endsWith(`${moduleName}.module/module.html`);
|
|
1142
|
+
});
|
|
1143
|
+
if (target) target.click();
|
|
1144
|
+
}, 80);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Lightweight HubL validity check — flags the same balance issues the
|
|
1148
|
+
// server-side validator would auto-fix, plus reserved field/ deprecated type
|
|
1149
|
+
// markers that the server already strips. The badge is a hint, not a gate.
|
|
1150
|
+
function quickCheckHubl(files) {
|
|
1151
|
+
const issues = [];
|
|
1152
|
+
const html = files && typeof files.moduleHtml === "string" ? files.moduleHtml : "";
|
|
1153
|
+
if (!html) return issues;
|
|
1154
|
+
|
|
1155
|
+
const blockOpeners = ["if", "for", "block", "with", "macro", "filter", "raw", "scope_css"];
|
|
1156
|
+
const stack = [];
|
|
1157
|
+
const tagRe = /\{%-?\s*(end)?(\w+)/g;
|
|
1158
|
+
let match;
|
|
1159
|
+
while ((match = tagRe.exec(html))) {
|
|
1160
|
+
const isEnd = !!match[1];
|
|
1161
|
+
const name = match[2];
|
|
1162
|
+
if (isEnd) {
|
|
1163
|
+
if (!stack.length || stack[stack.length - 1] !== name) {
|
|
1164
|
+
issues.push({ kind: "unbalanced_close", tag: name, message: `Stray {% end${name} %}` });
|
|
1165
|
+
} else {
|
|
1166
|
+
stack.pop();
|
|
1167
|
+
}
|
|
1168
|
+
} else if (blockOpeners.includes(name)) {
|
|
1169
|
+
stack.push(name);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
for (const open of stack) {
|
|
1173
|
+
issues.push({ kind: "unbalanced_open", tag: open, message: `Missing {% end${open} %}` });
|
|
1174
|
+
}
|
|
1175
|
+
return issues;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function startPipelineEstimate() {
|
|
1179
|
+
stopPipelineEstimate();
|
|
1180
|
+
if (!pipelineEstimateEl) return;
|
|
1181
|
+
pipelineEstimateEl.hidden = false;
|
|
1182
|
+
updatePipelineEstimate();
|
|
1183
|
+
pipelineEstimateInterval = setInterval(updatePipelineEstimate, 1000);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function stopPipelineEstimate() {
|
|
1187
|
+
if (pipelineEstimateInterval) {
|
|
1188
|
+
clearInterval(pipelineEstimateInterval);
|
|
1189
|
+
pipelineEstimateInterval = null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function updatePipelineEstimate() {
|
|
1194
|
+
if (!pipelineEstimateEl || pipelineCurrentStep !== "developing" || !pipelineModulesEl) return;
|
|
1195
|
+
const cards = pipelineModulesEl.querySelectorAll(".pipeline-module-card");
|
|
1196
|
+
if (!cards.length) {
|
|
1197
|
+
pipelineEstimateEl.textContent = "Estimating…";
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
let remaining = 0;
|
|
1201
|
+
let inFlight = 0;
|
|
1202
|
+
cards.forEach((c) => {
|
|
1203
|
+
if (c.classList.contains("pipeline-module-card--complete") || c.classList.contains("pipeline-module-card--failed")) return;
|
|
1204
|
+
remaining++;
|
|
1205
|
+
if (
|
|
1206
|
+
c.classList.contains("pipeline-module-card--generating") ||
|
|
1207
|
+
c.classList.contains("pipeline-module-card--validating") ||
|
|
1208
|
+
c.classList.contains("pipeline-module-card--retrying")
|
|
1209
|
+
) {
|
|
1210
|
+
inFlight++;
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
if (remaining === 0) {
|
|
1214
|
+
pipelineEstimateEl.textContent = "Wrapping up…";
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
// Default concurrency limiter is 20 modules, but most chats only have a
|
|
1218
|
+
// handful — assume 4-way effective parallelism for the estimate.
|
|
1219
|
+
const parallel = Math.max(1, Math.min(4, inFlight || 1));
|
|
1220
|
+
const seconds = Math.max(5, Math.ceil((remaining * ESTIMATED_SECONDS_PER_MODULE) / parallel));
|
|
1221
|
+
pipelineEstimateEl.textContent = `~${formatEstimate(seconds)} remaining`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function formatEstimate(totalSec) {
|
|
1225
|
+
if (totalSec < 60) return totalSec + "s";
|
|
1226
|
+
const m = Math.floor(totalSec / 60);
|
|
1227
|
+
const s = totalSec % 60;
|
|
1228
|
+
if (s === 0) return m + "m";
|
|
1229
|
+
return m + "m " + s + "s";
|
|
1230
|
+
}
|
|
1231
|
+
|
|
411
1232
|
function handlePipelineComplete(msg) {
|
|
412
1233
|
if (!pipelineBubbleEl) return;
|
|
413
1234
|
|
|
414
1235
|
stopPipelineTimer();
|
|
1236
|
+
stopPipelineEstimate();
|
|
415
1237
|
if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
|
|
416
1238
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1239
|
+
STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
|
|
1240
|
+
pipelineCurrentStep = null;
|
|
1241
|
+
setPipelineDetail("");
|
|
1242
|
+
|
|
1243
|
+
const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
|
|
1244
|
+
if (footer) footer.remove();
|
|
420
1245
|
|
|
421
|
-
// Mark all steps as done (search whole bubble since quality_check is outside pipelineStepsEl)
|
|
422
1246
|
const bubble = streamingMsgEl || pipelineBubbleEl;
|
|
423
|
-
bubble.querySelectorAll(".pipeline-step").forEach((el) => markStepDone(el));
|
|
424
1247
|
|
|
425
|
-
// Show answer text for question intents
|
|
426
1248
|
if (msg.answer) {
|
|
427
1249
|
const answerEl = document.createElement("div");
|
|
428
1250
|
answerEl.className = "pipeline-answer";
|
|
429
1251
|
answerEl.textContent = msg.answer;
|
|
430
1252
|
bubble.appendChild(answerEl);
|
|
1253
|
+
} else if (msg.assistantMessage) {
|
|
1254
|
+
streamBuffer = msg.assistantMessage;
|
|
431
1255
|
}
|
|
432
1256
|
|
|
433
|
-
|
|
1257
|
+
clearStreamStatus();
|
|
1258
|
+
finishStreaming();
|
|
1259
|
+
|
|
434
1260
|
const stats = document.createElement("div");
|
|
435
1261
|
stats.className = "pipeline-stats";
|
|
436
1262
|
const duration = formatDuration(msg.durationMs);
|
|
437
1263
|
if (msg.answer) {
|
|
438
|
-
// For questions, just show duration
|
|
439
1264
|
stats.textContent = `Answered in ${duration}`;
|
|
440
1265
|
} else {
|
|
441
1266
|
stats.textContent = `Generated ${msg.modulesGenerated} module${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
|
|
@@ -443,56 +1268,148 @@ function handlePipelineComplete(msg) {
|
|
|
443
1268
|
stats.textContent += ` (${msg.modulesUnchanged} unchanged)`;
|
|
444
1269
|
}
|
|
445
1270
|
}
|
|
446
|
-
|
|
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
|
-
}
|
|
455
|
-
|
|
456
|
-
clearStreamStatus();
|
|
457
|
-
finishStreaming();
|
|
1271
|
+
bubble.appendChild(stats);
|
|
458
1272
|
|
|
459
|
-
|
|
460
|
-
pipelineBubbleEl = null;
|
|
461
|
-
pipelineStepsEl = null;
|
|
462
|
-
pipelineModulesEl = null;
|
|
1273
|
+
resetPipelineState();
|
|
463
1274
|
}
|
|
464
1275
|
|
|
465
1276
|
function handlePipelinePartial(msg) {
|
|
466
1277
|
if (!pipelineBubbleEl) return;
|
|
467
1278
|
|
|
468
1279
|
stopPipelineTimer();
|
|
1280
|
+
stopPipelineEstimate();
|
|
469
1281
|
if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
|
|
470
1282
|
|
|
471
|
-
|
|
472
|
-
|
|
1283
|
+
STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
|
|
1284
|
+
pipelineCurrentStep = null;
|
|
1285
|
+
setPipelineDetail("");
|
|
1286
|
+
|
|
1287
|
+
const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
|
|
1288
|
+
if (footer) footer.remove();
|
|
473
1289
|
|
|
474
1290
|
const bubble = streamingMsgEl || pipelineBubbleEl;
|
|
475
|
-
|
|
1291
|
+
|
|
1292
|
+
clearStreamStatus();
|
|
1293
|
+
finishStreaming();
|
|
476
1294
|
|
|
477
1295
|
const stats = document.createElement("div");
|
|
478
1296
|
stats.className = "pipeline-stats pipeline-stats--partial";
|
|
479
1297
|
const duration = formatDuration(msg.durationMs);
|
|
480
1298
|
stats.textContent = `${msg.succeeded.length} modules succeeded, ${msg.failed.length} failed in ${duration}`;
|
|
481
|
-
|
|
482
|
-
if (qualityStep) {
|
|
483
|
-
qualityStep.after(stats);
|
|
484
|
-
} else if (pipelineModulesEl) {
|
|
485
|
-
pipelineModulesEl.after(stats);
|
|
486
|
-
} else {
|
|
487
|
-
pipelineStepsEl.appendChild(stats);
|
|
488
|
-
}
|
|
1299
|
+
bubble.appendChild(stats);
|
|
489
1300
|
|
|
490
|
-
|
|
491
|
-
|
|
1301
|
+
resetPipelineState();
|
|
1302
|
+
}
|
|
492
1303
|
|
|
1304
|
+
function resetPipelineState() {
|
|
493
1305
|
pipelineBubbleEl = null;
|
|
494
|
-
|
|
1306
|
+
pipelineStepperEl = null;
|
|
1307
|
+
pipelineDetailEl = null;
|
|
495
1308
|
pipelineModulesEl = null;
|
|
1309
|
+
pipelineEstimateEl = null;
|
|
1310
|
+
pipelineCurrentStep = null;
|
|
1311
|
+
|
|
1312
|
+
renderChatSuggestions();
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
// Smart chat suggestions — contextual next-step chips after generation
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
|
|
1319
|
+
let suggestionsVisible = false;
|
|
1320
|
+
|
|
1321
|
+
function getCurrentModuleNames() {
|
|
1322
|
+
return Array.from(document.querySelectorAll("#module-items .module-item"))
|
|
1323
|
+
.map((el) => (el.dataset.module || "").toLowerCase());
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function pickContextualSuggestions(moduleNames) {
|
|
1327
|
+
const has = (kw) => moduleNames.some((n) => n.includes(kw));
|
|
1328
|
+
const additive = [];
|
|
1329
|
+
|
|
1330
|
+
if (!has("testimonial") && !has("review") && !has("quote")) {
|
|
1331
|
+
additive.push("Add a testimonials module");
|
|
1332
|
+
}
|
|
1333
|
+
if (!has("pricing") && !has("plan") && !has("tier")) {
|
|
1334
|
+
additive.push("Add a pricing table");
|
|
1335
|
+
}
|
|
1336
|
+
if (!has("faq") && !has("question")) {
|
|
1337
|
+
additive.push("Add an FAQ module");
|
|
1338
|
+
}
|
|
1339
|
+
if (!has("contact") && !has("form")) {
|
|
1340
|
+
additive.push("Add a contact form");
|
|
1341
|
+
}
|
|
1342
|
+
if (!has("hero") && !has("banner")) {
|
|
1343
|
+
additive.push("Add a hero module");
|
|
1344
|
+
}
|
|
1345
|
+
if (!has("feature") && !has("benefit")) {
|
|
1346
|
+
additive.push("Add a features grid with icons");
|
|
1347
|
+
}
|
|
1348
|
+
if (!has("footer")) {
|
|
1349
|
+
additive.push("Add a footer with social links");
|
|
1350
|
+
}
|
|
1351
|
+
if (!has("stat") && !has("metric") && !has("number")) {
|
|
1352
|
+
additive.push("Add a stats module with key numbers");
|
|
1353
|
+
}
|
|
1354
|
+
if (!has("cta") && !has("call")) {
|
|
1355
|
+
additive.push("Add a call-to-action module");
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const refinements = [
|
|
1359
|
+
has("hero") || has("banner")
|
|
1360
|
+
? "Make the hero CTA more prominent"
|
|
1361
|
+
: "Strengthen the opening hook",
|
|
1362
|
+
"Change the color scheme to something bolder",
|
|
1363
|
+
"Refine the typography for a more modern feel",
|
|
1364
|
+
"Add subtle animations to bring it to life",
|
|
1365
|
+
];
|
|
1366
|
+
|
|
1367
|
+
const out = [];
|
|
1368
|
+
for (const s of additive) {
|
|
1369
|
+
if (out.length >= 3) break;
|
|
1370
|
+
out.push(s);
|
|
1371
|
+
}
|
|
1372
|
+
for (const r of refinements) {
|
|
1373
|
+
if (out.length >= 3) break;
|
|
1374
|
+
out.push(r);
|
|
1375
|
+
}
|
|
1376
|
+
return out;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function renderChatSuggestions() {
|
|
1380
|
+
const container = document.getElementById("chat-suggestions");
|
|
1381
|
+
if (!container) return;
|
|
1382
|
+
|
|
1383
|
+
if (isStreaming || (inputEl && inputEl.value && inputEl.value.length > 0)) {
|
|
1384
|
+
hideChatSuggestions();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const suggestions = pickContextualSuggestions(getCurrentModuleNames());
|
|
1389
|
+
if (!suggestions.length) {
|
|
1390
|
+
hideChatSuggestions();
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
container.innerHTML = "";
|
|
1395
|
+
for (const text of suggestions) {
|
|
1396
|
+
const btn = document.createElement("button");
|
|
1397
|
+
btn.type = "button";
|
|
1398
|
+
btn.className = "chat__suggestion-chip";
|
|
1399
|
+
btn.dataset.suggestion = text;
|
|
1400
|
+
btn.textContent = text;
|
|
1401
|
+
container.appendChild(btn);
|
|
1402
|
+
}
|
|
1403
|
+
container.classList.remove("hidden");
|
|
1404
|
+
suggestionsVisible = true;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function hideChatSuggestions() {
|
|
1408
|
+
const container = document.getElementById("chat-suggestions");
|
|
1409
|
+
if (!container) return;
|
|
1410
|
+
container.classList.add("hidden");
|
|
1411
|
+
container.innerHTML = "";
|
|
1412
|
+
suggestionsVisible = false;
|
|
496
1413
|
}
|
|
497
1414
|
|
|
498
1415
|
async function handleAgenticPrompt() {
|
|
@@ -549,8 +1466,11 @@ function handleSuggestBrandExtraction() {
|
|
|
549
1466
|
scrollToBottom();
|
|
550
1467
|
|
|
551
1468
|
el.querySelector("#btn-accept-extraction").addEventListener("click", () => {
|
|
552
|
-
el.querySelector(".
|
|
553
|
-
|
|
1469
|
+
const container = el.querySelector(".chat-msg__system");
|
|
1470
|
+
if (container) {
|
|
1471
|
+
container.innerHTML =
|
|
1472
|
+
'<span class="brand-extraction-prompt__status">Extracting…</span>';
|
|
1473
|
+
}
|
|
554
1474
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
555
1475
|
ws.send(JSON.stringify({ type: "extract_brand_assets" }));
|
|
556
1476
|
}
|
|
@@ -757,6 +1677,16 @@ async function sendMessage(text) {
|
|
|
757
1677
|
// Show user message with file chips
|
|
758
1678
|
appendUserMessage(text, null, uploadedFiles);
|
|
759
1679
|
|
|
1680
|
+
// Snapshot the current module list so we can detect new vs. modified modules
|
|
1681
|
+
// when the generation finishes. Clears stale change-indicator dots from any
|
|
1682
|
+
// previous run so the user only sees indicators for the run they just kicked.
|
|
1683
|
+
modulesBeforeRun = new Set(
|
|
1684
|
+
Array.from(document.querySelectorAll(".module-item")).map((el) => el.dataset.module),
|
|
1685
|
+
);
|
|
1686
|
+
changedModulesInRun = new Set();
|
|
1687
|
+
clearModuleListChanged();
|
|
1688
|
+
if (typeof window.resetHublCheck === "function") window.resetHublCheck();
|
|
1689
|
+
|
|
760
1690
|
// Start streaming indicator
|
|
761
1691
|
startStreaming();
|
|
762
1692
|
|
|
@@ -808,6 +1738,15 @@ function startStreaming() {
|
|
|
808
1738
|
lastStreamStatus = "";
|
|
809
1739
|
sendBtn.disabled = true;
|
|
810
1740
|
streamStartTime = Date.now();
|
|
1741
|
+
if (typeof window.setSelectModeDisabled === "function") {
|
|
1742
|
+
window.setSelectModeDisabled(true);
|
|
1743
|
+
}
|
|
1744
|
+
if (typeof window.setEditModeDisabled === "function") {
|
|
1745
|
+
window.setEditModeDisabled(true);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
hideChatSuggestions();
|
|
1749
|
+
if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
|
|
811
1750
|
|
|
812
1751
|
// Don't show generating preview here — agentic mode keeps the page visible.
|
|
813
1752
|
// For single-call mode, showGeneratingPreview() is called on first "stream" event.
|
|
@@ -950,6 +1889,13 @@ function finishStreaming() {
|
|
|
950
1889
|
if (!isStreaming) return;
|
|
951
1890
|
isStreaming = false;
|
|
952
1891
|
sendBtn.disabled = false;
|
|
1892
|
+
if (typeof window.setSelectModeDisabled === "function") {
|
|
1893
|
+
window.setSelectModeDisabled(false);
|
|
1894
|
+
}
|
|
1895
|
+
if (typeof window.setEditModeDisabled === "function") {
|
|
1896
|
+
window.setEditModeDisabled(false);
|
|
1897
|
+
}
|
|
1898
|
+
if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
|
|
953
1899
|
|
|
954
1900
|
// Stop the timer and capture duration
|
|
955
1901
|
stopStreamTimer();
|
|
@@ -1071,6 +2017,46 @@ function setStatus(text) {
|
|
|
1071
2017
|
statusText.textContent = text;
|
|
1072
2018
|
}
|
|
1073
2019
|
|
|
2020
|
+
function setStatusTheme(name) {
|
|
2021
|
+
if (!statusTheme) return;
|
|
2022
|
+
const value = (name || "").trim();
|
|
2023
|
+
statusTheme.textContent = value && value !== "—" ? value : "";
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function formatRelativeSaved(ts) {
|
|
2027
|
+
if (!ts) return "";
|
|
2028
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
2029
|
+
const sec = Math.round(diff / 1000);
|
|
2030
|
+
if (sec < 10) return "just now";
|
|
2031
|
+
if (sec < 60) return `${sec} sec ago`;
|
|
2032
|
+
const min = Math.round(sec / 60);
|
|
2033
|
+
if (min < 60) return `${min} min ago`;
|
|
2034
|
+
const hr = Math.round(min / 60);
|
|
2035
|
+
if (hr < 24) return `${hr} hr ago`;
|
|
2036
|
+
const day = Math.round(hr / 24);
|
|
2037
|
+
return `${day} day${day === 1 ? "" : "s"} ago`;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function renderLastSaved() {
|
|
2041
|
+
if (!statusLastSaved) return;
|
|
2042
|
+
if (!lastSavedAt) {
|
|
2043
|
+
statusLastSaved.textContent = "";
|
|
2044
|
+
statusLastSaved.title = "";
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
statusLastSaved.textContent = formatRelativeSaved(lastSavedAt);
|
|
2048
|
+
try {
|
|
2049
|
+
statusLastSaved.title = new Date(lastSavedAt).toLocaleString();
|
|
2050
|
+
} catch { /* ignore */ }
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function markSaved(ts) {
|
|
2054
|
+
lastSavedAt = typeof ts === "number" && ts > 0 ? ts : Date.now();
|
|
2055
|
+
renderLastSaved();
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
setInterval(renderLastSaved, 30000);
|
|
2059
|
+
|
|
1074
2060
|
// ---------------------------------------------------------------------------
|
|
1075
2061
|
// Restored / system messages
|
|
1076
2062
|
// ---------------------------------------------------------------------------
|
|
@@ -1081,21 +2067,40 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
|
|
|
1081
2067
|
div.className = "chat-msg chat-msg--assistant";
|
|
1082
2068
|
|
|
1083
2069
|
if (pipeline && pipeline.steps && pipeline.steps.length > 0) {
|
|
1084
|
-
//
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
2070
|
+
// Finalized stepper — every visited stage shown as done
|
|
2071
|
+
const stagesSeen = new Set(pipeline.steps.map((s) => s.step));
|
|
2072
|
+
const stepperParts = [];
|
|
2073
|
+
for (let i = 0; i < STEP_ORDER.length; i++) {
|
|
2074
|
+
const step = STEP_ORDER[i];
|
|
2075
|
+
const visited = stagesSeen.has(step);
|
|
2076
|
+
const stateClass = visited ? "pipeline-stage--done" : "pipeline-stage--pending";
|
|
2077
|
+
stepperParts.push(`
|
|
2078
|
+
<div class="pipeline-stage ${stateClass}" data-stage="${step}">
|
|
2079
|
+
<div class="pipeline-stage__indicator">
|
|
2080
|
+
<span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
|
|
2081
|
+
<span class="pipeline-stage__check">${CHECK_ICON}</span>
|
|
2082
|
+
</div>
|
|
2083
|
+
<div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
|
|
2084
|
+
</div>`);
|
|
2085
|
+
if (i < STEP_ORDER.length - 1) {
|
|
2086
|
+
const filled = visited && stagesSeen.has(STEP_ORDER[i + 1]) ? "pipeline-stage__connector--filled" : "";
|
|
2087
|
+
stepperParts.push(`<div class="pipeline-stage__connector ${filled}" data-after="${step}"></div>`);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const allDecisions = pipeline.steps.flatMap((s) => s.decisions || []);
|
|
2092
|
+
const decisionHtml = allDecisions.length
|
|
2093
|
+
? `<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>`
|
|
2094
|
+
: "";
|
|
1092
2095
|
|
|
1093
2096
|
const modulesHtml = pipeline.modules && pipeline.modules.length > 0
|
|
1094
|
-
? pipeline
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
2097
|
+
? `<div class="pipeline-modules pipeline-modules--restored">${pipeline.modules
|
|
2098
|
+
.map((m) => {
|
|
2099
|
+
const statusClass = m.status === "failed" ? "pipeline-module-card--failed" : "pipeline-module-card--complete";
|
|
2100
|
+
const statusLabel = m.status === "failed" ? "failed" : "done";
|
|
2101
|
+
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>`;
|
|
2102
|
+
})
|
|
2103
|
+
.join("")}</div>`
|
|
1099
2104
|
: "";
|
|
1100
2105
|
|
|
1101
2106
|
const duration = formatDuration(pipeline.stats.durationMs);
|
|
@@ -1110,7 +2115,10 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
|
|
|
1110
2115
|
<div class="chat-msg__content">
|
|
1111
2116
|
${time ? `<div class="chat-msg__header"><span class="chat-msg__sender">vibeSpot AI</span><span class="chat-msg__time">${time}</span></div>` : ""}
|
|
1112
2117
|
<div class="chat-msg__bubble">
|
|
1113
|
-
<div class="pipeline-
|
|
2118
|
+
<div class="pipeline-stepper">${stepperParts.join("")}</div>
|
|
2119
|
+
${decisionHtml}
|
|
2120
|
+
${modulesHtml}
|
|
2121
|
+
<div class="${statsClass}">${statsText}</div>
|
|
1114
2122
|
</div>
|
|
1115
2123
|
</div>`;
|
|
1116
2124
|
} else {
|
|
@@ -1278,6 +2286,303 @@ function timeAgoShort(timestamp) {
|
|
|
1278
2286
|
return days + "d";
|
|
1279
2287
|
}
|
|
1280
2288
|
|
|
2289
|
+
// ---------------------------------------------------------------------------
|
|
2290
|
+
// History timeline — compact strip above chat input with undo/redo support
|
|
2291
|
+
// ---------------------------------------------------------------------------
|
|
2292
|
+
|
|
2293
|
+
let historyGitAvailable = false;
|
|
2294
|
+
// Index into historyTimelineEntries (filtered list, newest first) representing
|
|
2295
|
+
// the version the user is currently viewing. 0 = head, N = N steps back.
|
|
2296
|
+
let historyTimelineCursor = 0;
|
|
2297
|
+
let historyTimelineEntries = [];
|
|
2298
|
+
let historyTimelineRestoring = false;
|
|
2299
|
+
|
|
2300
|
+
function shortenCommitMessage(msg) {
|
|
2301
|
+
if (!msg) return "Update";
|
|
2302
|
+
let s = msg;
|
|
2303
|
+
// Strip [templateId] prefix
|
|
2304
|
+
const prefix = s.match(/^\[[^\]]+\]\s*/);
|
|
2305
|
+
if (prefix) s = s.slice(prefix[0].length);
|
|
2306
|
+
// Strip leading "Rollback to: "
|
|
2307
|
+
s = s.replace(/^Rollback to:\s*/i, "");
|
|
2308
|
+
if (s.length > 32) s = s.slice(0, 31) + "…";
|
|
2309
|
+
return s;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
function stripCommitPrefix(msg) {
|
|
2313
|
+
return (msg || "").replace(/^\[[^\]]+\]\s*/, "");
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Compute which timeline entry represents the *currently visible* version.
|
|
2317
|
+
// HEAD may be a "Rollback to: <original>" commit (an internal marker we filter
|
|
2318
|
+
// out of the visible timeline); in that case, we map back to the original
|
|
2319
|
+
// entry by message. Otherwise the cursor sits at HEAD itself.
|
|
2320
|
+
function computeCursorFromHead(commits, entries) {
|
|
2321
|
+
if (!commits.length || !entries.length) return 0;
|
|
2322
|
+
const headMsg = stripCommitPrefix(commits[0].message);
|
|
2323
|
+
if (/^rollback to:\s*/i.test(headMsg)) {
|
|
2324
|
+
const orig = headMsg.replace(/^rollback to:\s*/i, "").trim().replace(/\.{3}$/, "");
|
|
2325
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2326
|
+
const em = stripCommitPrefix(entries[i].message);
|
|
2327
|
+
if (em === orig || em.startsWith(orig)) return i;
|
|
2328
|
+
}
|
|
2329
|
+
return 0;
|
|
2330
|
+
}
|
|
2331
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2332
|
+
if (entries[i].fullHash === commits[0].fullHash) return i;
|
|
2333
|
+
}
|
|
2334
|
+
return 0;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
async function refreshHistoryTimeline() {
|
|
2338
|
+
const tl = document.getElementById("history-timeline");
|
|
2339
|
+
const track = document.getElementById("history-timeline-track");
|
|
2340
|
+
if (!tl || !track) return;
|
|
2341
|
+
|
|
2342
|
+
if (!historyGitAvailable) {
|
|
2343
|
+
tl.classList.add("hidden");
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
try {
|
|
2348
|
+
const useFilter = currentTemplateId;
|
|
2349
|
+
const url = useFilter
|
|
2350
|
+
? `/api/history?templateId=${encodeURIComponent(currentTemplateId)}`
|
|
2351
|
+
: "/api/history";
|
|
2352
|
+
const res = await fetch(url);
|
|
2353
|
+
const data = await res.json();
|
|
2354
|
+
if (!data.available) {
|
|
2355
|
+
tl.classList.add("hidden");
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
const allCommits = data.commits || [];
|
|
2360
|
+
// Filter out automatic "Rollback to:" commits — they're internal markers.
|
|
2361
|
+
// The user navigates the conceptual history of generation events.
|
|
2362
|
+
const entries = allCommits.filter((c) => {
|
|
2363
|
+
return !/^rollback to:\s*/i.test(stripCommitPrefix(c.message));
|
|
2364
|
+
});
|
|
2365
|
+
historyTimelineEntries = entries;
|
|
2366
|
+
|
|
2367
|
+
if (entries.length === 0) {
|
|
2368
|
+
tl.classList.add("hidden");
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
historyTimelineCursor = computeCursorFromHead(allCommits, entries);
|
|
2373
|
+
|
|
2374
|
+
tl.classList.remove("hidden");
|
|
2375
|
+
track.innerHTML = "";
|
|
2376
|
+
const frag = document.createDocumentFragment();
|
|
2377
|
+
entries.forEach((commit, idx) => {
|
|
2378
|
+
const isInitial = (commit.message || "").startsWith("Initial ");
|
|
2379
|
+
const item = document.createElement("button");
|
|
2380
|
+
item.type = "button";
|
|
2381
|
+
item.className = "history-timeline__entry"
|
|
2382
|
+
+ (idx === historyTimelineCursor ? " history-timeline__entry--current" : "")
|
|
2383
|
+
+ (isInitial ? " history-timeline__entry--initial" : "");
|
|
2384
|
+
item.dataset.hash = commit.fullHash;
|
|
2385
|
+
item.dataset.index = String(idx);
|
|
2386
|
+
item.innerHTML = `<span class="history-timeline__entry-dot"></span>`
|
|
2387
|
+
+ `<span class="history-timeline__entry-label"></span>`;
|
|
2388
|
+
item.querySelector(".history-timeline__entry-label").textContent = shortenCommitMessage(commit.message);
|
|
2389
|
+
item.title = ""; // we use a custom tooltip
|
|
2390
|
+
frag.appendChild(item);
|
|
2391
|
+
});
|
|
2392
|
+
track.appendChild(frag);
|
|
2393
|
+
|
|
2394
|
+
updateHistoryTimelineNavState();
|
|
2395
|
+
|
|
2396
|
+
// Scroll the current entry into view
|
|
2397
|
+
requestAnimationFrame(() => {
|
|
2398
|
+
const cur = track.querySelector(".history-timeline__entry--current");
|
|
2399
|
+
if (cur && typeof cur.scrollIntoView === "function") {
|
|
2400
|
+
cur.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2403
|
+
} catch {
|
|
2404
|
+
tl.classList.add("hidden");
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function updateHistoryTimelineNavState() {
|
|
2409
|
+
const undoBtn = document.getElementById("history-timeline-undo");
|
|
2410
|
+
const redoBtn = document.getElementById("history-timeline-redo");
|
|
2411
|
+
const topbarUndo = document.getElementById("btn-undo");
|
|
2412
|
+
const topbarRedo = document.getElementById("btn-redo");
|
|
2413
|
+
const max = historyTimelineEntries.length - 1;
|
|
2414
|
+
const busy = historyTimelineRestoring || (typeof isStreaming !== "undefined" && isStreaming);
|
|
2415
|
+
const undoDisabled = busy || !historyGitAvailable || historyTimelineCursor >= max;
|
|
2416
|
+
const redoDisabled = busy || !historyGitAvailable || historyTimelineCursor <= 0;
|
|
2417
|
+
if (undoBtn) undoBtn.disabled = undoDisabled;
|
|
2418
|
+
if (redoBtn) redoBtn.disabled = redoDisabled;
|
|
2419
|
+
if (topbarUndo) topbarUndo.disabled = undoDisabled;
|
|
2420
|
+
if (topbarRedo) topbarRedo.disabled = redoDisabled;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
async function restoreToTimelineIndex(idx) {
|
|
2424
|
+
if (historyTimelineRestoring) return;
|
|
2425
|
+
if (idx < 0 || idx >= historyTimelineEntries.length) return;
|
|
2426
|
+
if (typeof isStreaming !== "undefined" && isStreaming) return;
|
|
2427
|
+
const target = historyTimelineEntries[idx];
|
|
2428
|
+
if (!target) return;
|
|
2429
|
+
|
|
2430
|
+
historyTimelineRestoring = true;
|
|
2431
|
+
updateHistoryTimelineNavState();
|
|
2432
|
+
setStatus("Restoring…");
|
|
2433
|
+
|
|
2434
|
+
try {
|
|
2435
|
+
const payload = { hash: target.fullHash };
|
|
2436
|
+
if (currentTemplateId) payload.templateId = currentTemplateId;
|
|
2437
|
+
const res = await fetch("/api/rollback", {
|
|
2438
|
+
method: "POST",
|
|
2439
|
+
headers: { "Content-Type": "application/json" },
|
|
2440
|
+
body: JSON.stringify(payload),
|
|
2441
|
+
});
|
|
2442
|
+
const data = await res.json();
|
|
2443
|
+
if (data.error) {
|
|
2444
|
+
await vibeAlert(data.error, "Restore failed");
|
|
2445
|
+
setStatus("Ready");
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
if (data.modules) updateModuleList(data.modules);
|
|
2449
|
+
refreshPreview();
|
|
2450
|
+
// Refresh from server — the new HEAD is a "Rollback to:" commit pointing
|
|
2451
|
+
// at the entry we just restored; computeCursorFromHead resolves it back.
|
|
2452
|
+
await refreshHistoryTimeline();
|
|
2453
|
+
if (historyPanelOpen) refreshHistoryPanel();
|
|
2454
|
+
setStatus("Ready");
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
await vibeAlert(err.message || "Restore failed", "Restore failed");
|
|
2457
|
+
setStatus("Ready");
|
|
2458
|
+
} finally {
|
|
2459
|
+
historyTimelineRestoring = false;
|
|
2460
|
+
updateHistoryTimelineNavState();
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
function timelineUndo() {
|
|
2465
|
+
restoreToTimelineIndex(historyTimelineCursor + 1);
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
function timelineRedo() {
|
|
2469
|
+
restoreToTimelineIndex(historyTimelineCursor - 1);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// ---- Tooltip on hover ------------------------------------------------------
|
|
2473
|
+
|
|
2474
|
+
function showTimelineTooltip(entryEl) {
|
|
2475
|
+
const tooltip = document.getElementById("history-timeline-tooltip");
|
|
2476
|
+
const tl = document.getElementById("history-timeline");
|
|
2477
|
+
if (!tooltip || !tl || !entryEl) return;
|
|
2478
|
+
const idx = parseInt(entryEl.dataset.index || "-1", 10);
|
|
2479
|
+
const commit = historyTimelineEntries[idx];
|
|
2480
|
+
if (!commit) return;
|
|
2481
|
+
|
|
2482
|
+
const modules = (commit.changedModules || []).slice(0, 5);
|
|
2483
|
+
const more = (commit.changedModules || []).length - modules.length;
|
|
2484
|
+
const modulesLine = modules.length
|
|
2485
|
+
? modules.join(", ") + (more > 0 ? ` +${more} more` : "")
|
|
2486
|
+
: "No module changes";
|
|
2487
|
+
|
|
2488
|
+
// Strip [templateId] prefix for display; keep "Rollback to:" prefix because
|
|
2489
|
+
// that case is filtered out earlier and won't reach here.
|
|
2490
|
+
let displayMsg = commit.message || "";
|
|
2491
|
+
const prefix = displayMsg.match(/^\[[^\]]+\]\s*/);
|
|
2492
|
+
if (prefix) displayMsg = displayMsg.slice(prefix[0].length);
|
|
2493
|
+
|
|
2494
|
+
tooltip.innerHTML = `
|
|
2495
|
+
<div class="history-timeline__tooltip-title"></div>
|
|
2496
|
+
<div class="history-timeline__tooltip-meta"></div>
|
|
2497
|
+
<div class="history-timeline__tooltip-modules"></div>
|
|
2498
|
+
`;
|
|
2499
|
+
tooltip.querySelector(".history-timeline__tooltip-title").textContent = displayMsg || "Update";
|
|
2500
|
+
tooltip.querySelector(".history-timeline__tooltip-meta").textContent =
|
|
2501
|
+
`${commit.hash} · ${timeAgoShort(commit.timestamp)} ago`;
|
|
2502
|
+
tooltip.querySelector(".history-timeline__tooltip-modules").textContent = modulesLine;
|
|
2503
|
+
|
|
2504
|
+
// Position above the entry, clamped within the timeline strip.
|
|
2505
|
+
tooltip.classList.remove("hidden");
|
|
2506
|
+
const tlRect = tl.getBoundingClientRect();
|
|
2507
|
+
const entryRect = entryEl.getBoundingClientRect();
|
|
2508
|
+
const left = Math.max(8, entryRect.left - tlRect.left);
|
|
2509
|
+
const tooltipW = tooltip.offsetWidth;
|
|
2510
|
+
const maxLeft = Math.max(8, tl.clientWidth - tooltipW - 8);
|
|
2511
|
+
tooltip.style.left = Math.min(left, maxLeft) + "px";
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function hideTimelineTooltip() {
|
|
2515
|
+
const tooltip = document.getElementById("history-timeline-tooltip");
|
|
2516
|
+
if (tooltip) tooltip.classList.add("hidden");
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// ---- Wire up DOM events ----------------------------------------------------
|
|
2520
|
+
|
|
2521
|
+
(function wireHistoryTimeline() {
|
|
2522
|
+
const track = document.getElementById("history-timeline-track");
|
|
2523
|
+
const undoBtn = document.getElementById("history-timeline-undo");
|
|
2524
|
+
const redoBtn = document.getElementById("history-timeline-redo");
|
|
2525
|
+
|
|
2526
|
+
if (track) {
|
|
2527
|
+
track.addEventListener("click", (e) => {
|
|
2528
|
+
const entry = e.target.closest(".history-timeline__entry");
|
|
2529
|
+
if (!entry) return;
|
|
2530
|
+
const idx = parseInt(entry.dataset.index || "-1", 10);
|
|
2531
|
+
if (idx >= 0) restoreToTimelineIndex(idx);
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
let hoverTimer = null;
|
|
2535
|
+
track.addEventListener("mouseover", (e) => {
|
|
2536
|
+
const entry = e.target.closest(".history-timeline__entry");
|
|
2537
|
+
if (!entry) return;
|
|
2538
|
+
clearTimeout(hoverTimer);
|
|
2539
|
+
hoverTimer = setTimeout(() => showTimelineTooltip(entry), 200);
|
|
2540
|
+
});
|
|
2541
|
+
track.addEventListener("mouseout", (e) => {
|
|
2542
|
+
const entry = e.target.closest(".history-timeline__entry");
|
|
2543
|
+
if (!entry) return;
|
|
2544
|
+
clearTimeout(hoverTimer);
|
|
2545
|
+
hideTimelineTooltip();
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
if (undoBtn) undoBtn.addEventListener("click", timelineUndo);
|
|
2550
|
+
if (redoBtn) redoBtn.addEventListener("click", timelineRedo);
|
|
2551
|
+
|
|
2552
|
+
// Topbar undo/redo buttons mirror the timeline strip controls so the action
|
|
2553
|
+
// is discoverable without opening the history panel.
|
|
2554
|
+
const topbarUndo = document.getElementById("btn-undo");
|
|
2555
|
+
const topbarRedo = document.getElementById("btn-redo");
|
|
2556
|
+
if (topbarUndo) topbarUndo.addEventListener("click", timelineUndo);
|
|
2557
|
+
if (topbarRedo) topbarRedo.addEventListener("click", timelineRedo);
|
|
2558
|
+
|
|
2559
|
+
// Global Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z keyboard shortcuts. Skip when the
|
|
2560
|
+
// user is editing text so we never hijack native undo in inputs/editors.
|
|
2561
|
+
document.addEventListener("keydown", (e) => {
|
|
2562
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
2563
|
+
if (!meta) return;
|
|
2564
|
+
const key = e.key.toLowerCase();
|
|
2565
|
+
if (key !== "z" && key !== "y") return;
|
|
2566
|
+
|
|
2567
|
+
const active = document.activeElement;
|
|
2568
|
+
if (active) {
|
|
2569
|
+
const tag = (active.tagName || "").toLowerCase();
|
|
2570
|
+
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
|
2571
|
+
if (active.isContentEditable) return;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
if (!historyGitAvailable) return;
|
|
2575
|
+
|
|
2576
|
+
if (key === "z" && !e.shiftKey) {
|
|
2577
|
+
e.preventDefault();
|
|
2578
|
+
timelineUndo();
|
|
2579
|
+
} else if ((key === "z" && e.shiftKey) || key === "y") {
|
|
2580
|
+
e.preventDefault();
|
|
2581
|
+
timelineRedo();
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
})();
|
|
2585
|
+
|
|
1281
2586
|
// ---------------------------------------------------------------------------
|
|
1282
2587
|
// Module list
|
|
1283
2588
|
// ---------------------------------------------------------------------------
|
|
@@ -1289,17 +2594,28 @@ function updateModuleList(moduleNames) {
|
|
|
1289
2594
|
|
|
1290
2595
|
if (barCountEl) barCountEl.textContent = moduleNames.length;
|
|
1291
2596
|
if (slideoutCountEl) slideoutCountEl.textContent = moduleNames.length;
|
|
2597
|
+
const pageTreeCountEl = document.getElementById("page-tree-module-count");
|
|
2598
|
+
if (pageTreeCountEl) pageTreeCountEl.textContent = moduleNames.length;
|
|
2599
|
+
|
|
2600
|
+
// Preserve which items were marked as recently changed across re-renders so
|
|
2601
|
+
// the dots survive the per-module `modules_updated` events that happen
|
|
2602
|
+
// mid-run.
|
|
2603
|
+
const previouslyChanged = new Set(
|
|
2604
|
+
Array.from(itemsEl.querySelectorAll(".module-item--changed")).map((el) => el.dataset.module),
|
|
2605
|
+
);
|
|
1292
2606
|
itemsEl.innerHTML = "";
|
|
1293
2607
|
|
|
1294
2608
|
for (const name of moduleNames) {
|
|
1295
2609
|
const item = document.createElement("div");
|
|
1296
2610
|
item.className = "module-item";
|
|
2611
|
+
if (previouslyChanged.has(name)) item.classList.add("module-item--changed");
|
|
1297
2612
|
item.dataset.module = name;
|
|
1298
2613
|
item.innerHTML = `
|
|
1299
2614
|
<span class="module-item__drag">⠿</span>
|
|
2615
|
+
<span class="module-item__changed-dot" aria-hidden="true"></span>
|
|
1300
2616
|
<span class="module-item__name">${escapeHtml(name)}</span>
|
|
1301
|
-
<span class="module-item__edit" title="Edit fields"
|
|
1302
|
-
<span class="module-item__delete" title="Delete
|
|
2617
|
+
<span class="module-item__edit" title="Edit fields">${vsIcon("settings", {size: "sm"})}</span>
|
|
2618
|
+
<span class="module-item__delete" title="Delete section">×</span>
|
|
1303
2619
|
`;
|
|
1304
2620
|
|
|
1305
2621
|
item.querySelector(".module-item__edit").addEventListener("click", (e) => {
|
|
@@ -1325,12 +2641,79 @@ function highlightModuleItem(name) {
|
|
|
1325
2641
|
});
|
|
1326
2642
|
}
|
|
1327
2643
|
|
|
2644
|
+
/**
|
|
2645
|
+
* Flush change highlights at the end of a generation run: refresh the preview
|
|
2646
|
+
* with the changed/new module sets, mark the sidebar items, and schedule the
|
|
2647
|
+
* 30-second auto-clear of the dots.
|
|
2648
|
+
*/
|
|
2649
|
+
function flushChangeHighlights(latestModules) {
|
|
2650
|
+
const changed = Array.from(changedModulesInRun);
|
|
2651
|
+
const before = modulesBeforeRun;
|
|
2652
|
+
changedModulesInRun = new Set();
|
|
2653
|
+
modulesBeforeRun = null;
|
|
2654
|
+
|
|
2655
|
+
let newModules = [];
|
|
2656
|
+
if (before) {
|
|
2657
|
+
if (changed.length > 0) {
|
|
2658
|
+
// Agentic pipeline: we know exactly which modules ran. New = ran AND
|
|
2659
|
+
// not in the pre-run snapshot.
|
|
2660
|
+
newModules = changed.filter((m) => !before.has(m));
|
|
2661
|
+
} else if (Array.isArray(latestModules)) {
|
|
2662
|
+
// Single-call mode emits no per-module events. Fall back to "names that
|
|
2663
|
+
// appeared since the run started" — we can't know which existing
|
|
2664
|
+
// modules were rewritten without server-side support.
|
|
2665
|
+
newModules = latestModules.filter((m) => !before.has(m));
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
refreshPreview({
|
|
2670
|
+
changedModules: changed.length > 0 ? changed : newModules,
|
|
2671
|
+
newModules,
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
// Sidebar dots only fire when we have explicit per-module change info; the
|
|
2675
|
+
// single-call fallback above only knows about new modules, which already
|
|
2676
|
+
// get the slide-in animation in the preview.
|
|
2677
|
+
if (changed.length > 0) {
|
|
2678
|
+
markModuleListChanged(changed);
|
|
2679
|
+
} else if (newModules.length > 0) {
|
|
2680
|
+
markModuleListChanged(newModules);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/** Apply the change indicator dot to each named module in the sidebar. */
|
|
2685
|
+
function markModuleListChanged(names) {
|
|
2686
|
+
if (!names || names.length === 0) return;
|
|
2687
|
+
const itemsEl = document.getElementById("module-items");
|
|
2688
|
+
if (!itemsEl) return;
|
|
2689
|
+
const set = new Set(names);
|
|
2690
|
+
itemsEl.querySelectorAll(".module-item").forEach((el) => {
|
|
2691
|
+
if (set.has(el.dataset.module)) el.classList.add("module-item--changed");
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
// Auto-clear after 30s per spec; next generation also clears via sendMessage.
|
|
2695
|
+
if (changedListClearTimer) clearTimeout(changedListClearTimer);
|
|
2696
|
+
changedListClearTimer = setTimeout(clearModuleListChanged, 30000);
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
/** Remove all change indicator dots from the sidebar. */
|
|
2700
|
+
function clearModuleListChanged() {
|
|
2701
|
+
if (changedListClearTimer) {
|
|
2702
|
+
clearTimeout(changedListClearTimer);
|
|
2703
|
+
changedListClearTimer = null;
|
|
2704
|
+
}
|
|
2705
|
+
document.querySelectorAll(".module-item--changed").forEach((el) => {
|
|
2706
|
+
el.classList.remove("module-item--changed");
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
|
|
1328
2710
|
// ---------------------------------------------------------------------------
|
|
1329
2711
|
// Module slideout
|
|
1330
2712
|
// ---------------------------------------------------------------------------
|
|
1331
2713
|
|
|
1332
2714
|
function openModuleSlideout() {
|
|
1333
2715
|
const slideout = document.getElementById("module-slideout");
|
|
2716
|
+
slideout.classList.remove("hidden");
|
|
1334
2717
|
document.getElementById("module-list-view").classList.remove("hidden");
|
|
1335
2718
|
document.getElementById("module-editor-view").classList.add("hidden");
|
|
1336
2719
|
slideout.classList.add("open");
|
|
@@ -1341,9 +2724,11 @@ function closeModuleSlideout() {
|
|
|
1341
2724
|
}
|
|
1342
2725
|
|
|
1343
2726
|
function showEditorView(moduleName) {
|
|
2727
|
+
const slideout = document.getElementById("module-slideout");
|
|
2728
|
+
slideout.classList.remove("hidden");
|
|
1344
2729
|
document.getElementById("module-list-view").classList.add("hidden");
|
|
1345
2730
|
document.getElementById("module-editor-view").classList.remove("hidden");
|
|
1346
|
-
|
|
2731
|
+
slideout.classList.add("open");
|
|
1347
2732
|
}
|
|
1348
2733
|
|
|
1349
2734
|
function showModuleListView() {
|
|
@@ -1374,7 +2759,7 @@ async function toggleModuleLibraryDropdown() {
|
|
|
1374
2759
|
);
|
|
1375
2760
|
|
|
1376
2761
|
if (available.length === 0) {
|
|
1377
|
-
dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other
|
|
2762
|
+
dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other sections available</div>`;
|
|
1378
2763
|
} else {
|
|
1379
2764
|
dropdown.innerHTML = available.map((m) =>
|
|
1380
2765
|
`<button class="module-library-dropdown__item" data-name="${escapeHtml(m.moduleName)}">
|
|
@@ -1424,8 +2809,8 @@ async function addModuleFromLibrary(moduleName) {
|
|
|
1424
2809
|
}
|
|
1425
2810
|
}
|
|
1426
2811
|
|
|
1427
|
-
//
|
|
1428
|
-
document.getElementById("btn-modules")?.addEventListener("click", () => {
|
|
2812
|
+
// Toggle module slideout from page-tree header
|
|
2813
|
+
document.getElementById("btn-toggle-modules")?.addEventListener("click", () => {
|
|
1429
2814
|
const slideout = document.getElementById("module-slideout");
|
|
1430
2815
|
if (slideout.classList.contains("open")) {
|
|
1431
2816
|
closeModuleSlideout();
|
|
@@ -1460,7 +2845,7 @@ function confirmDeleteModule(moduleName) {
|
|
|
1460
2845
|
overlay.innerHTML = `
|
|
1461
2846
|
<div class="confirm-dialog">
|
|
1462
2847
|
<div class="confirm-dialog__title">Remove "${escapeHtml(moduleName)}"?</div>
|
|
1463
|
-
<p class="confirm-dialog__detail">
|
|
2848
|
+
<p class="confirm-dialog__detail">Section will be removed from this page but kept in your library.</p>
|
|
1464
2849
|
<label class="confirm-dialog__toggle">
|
|
1465
2850
|
<span class="confirm-dialog__toggle-switch">
|
|
1466
2851
|
<input type="checkbox" data-role="toggle" />
|
|
@@ -1650,30 +3035,99 @@ inputEl.addEventListener("keydown", (e) => {
|
|
|
1650
3035
|
}
|
|
1651
3036
|
});
|
|
1652
3037
|
|
|
1653
|
-
// Auto-grow textarea
|
|
1654
|
-
|
|
3038
|
+
// Auto-grow textarea — 3 rows default, expand up to 6 rows.
|
|
3039
|
+
// Line-height (1.5) × font-size (14px) × 6 rows + ~8px padding = ~134px.
|
|
3040
|
+
const CHAT_INPUT_MAX_HEIGHT = 134;
|
|
3041
|
+
function autoGrowChatInput() {
|
|
1655
3042
|
inputEl.style.height = "auto";
|
|
1656
|
-
inputEl.style.height = Math.min(inputEl.scrollHeight,
|
|
3043
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, CHAT_INPUT_MAX_HEIGHT) + "px";
|
|
3044
|
+
}
|
|
3045
|
+
inputEl.addEventListener("input", () => {
|
|
3046
|
+
autoGrowChatInput();
|
|
3047
|
+
|
|
3048
|
+
// Hide suggestion chips as soon as the user starts typing
|
|
3049
|
+
if (suggestionsVisible && inputEl.value.length > 0) {
|
|
3050
|
+
hideChatSuggestions();
|
|
3051
|
+
}
|
|
1657
3052
|
});
|
|
1658
3053
|
|
|
1659
|
-
//
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
3054
|
+
// Contextual placeholder — adapts to plan mode and whether a page already exists.
|
|
3055
|
+
function updateChatPlaceholder() {
|
|
3056
|
+
if (!inputEl) return;
|
|
3057
|
+
const planActive = !!window.planModeActive;
|
|
3058
|
+
const moduleCountEl = document.getElementById("module-count");
|
|
3059
|
+
const moduleCount = moduleCountEl ? parseInt(moduleCountEl.textContent || "0", 10) || 0 : 0;
|
|
3060
|
+
let placeholder;
|
|
3061
|
+
if (planActive) {
|
|
3062
|
+
placeholder = "Describe what you want to plan...";
|
|
3063
|
+
} else if (moduleCount > 0) {
|
|
3064
|
+
placeholder = "Describe what you want to change...";
|
|
3065
|
+
} else {
|
|
3066
|
+
placeholder = "Describe your landing page...";
|
|
3067
|
+
}
|
|
3068
|
+
inputEl.placeholder = placeholder;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// Re-evaluate placeholder when plan mode changes (plan.js dispatches this) or
|
|
3072
|
+
// when the module list updates.
|
|
3073
|
+
window.addEventListener("plan-mode-changed", updateChatPlaceholder);
|
|
3074
|
+
const _moduleCountEl = document.getElementById("module-count");
|
|
3075
|
+
if (_moduleCountEl && typeof MutationObserver !== "undefined") {
|
|
3076
|
+
new MutationObserver(updateChatPlaceholder).observe(_moduleCountEl, {
|
|
3077
|
+
childList: true,
|
|
3078
|
+
characterData: true,
|
|
3079
|
+
subtree: true,
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
updateChatPlaceholder();
|
|
3083
|
+
|
|
3084
|
+
// Suggestion chip click — pre-fill input and focus, do not auto-send so the
|
|
3085
|
+
// user can edit before pressing Enter.
|
|
3086
|
+
document.getElementById("chat-suggestions")?.addEventListener("click", (e) => {
|
|
3087
|
+
const chip = e.target.closest(".chat__suggestion-chip");
|
|
3088
|
+
if (!chip) return;
|
|
3089
|
+
const text = chip.dataset.suggestion || chip.textContent || "";
|
|
3090
|
+
inputEl.value = text;
|
|
3091
|
+
inputEl.focus();
|
|
3092
|
+
inputEl.setSelectionRange(text.length, text.length);
|
|
3093
|
+
// Trigger the input event so the textarea resizes; this also collapses chips
|
|
3094
|
+
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3095
|
+
hideChatSuggestions();
|
|
1663
3096
|
});
|
|
1664
3097
|
|
|
1665
|
-
//
|
|
3098
|
+
// Pre-fill chat input from preview select-mode click
|
|
3099
|
+
window.prefillChatInput = function (text) {
|
|
3100
|
+
if (!text) return;
|
|
3101
|
+
const existing = inputEl.value;
|
|
3102
|
+
const prefix = existing.trim() ? existing.replace(/\s+$/, "") + "\n\n" : "";
|
|
3103
|
+
inputEl.value = prefix + text;
|
|
3104
|
+
autoGrowChatInput();
|
|
3105
|
+
inputEl.focus();
|
|
3106
|
+
const end = inputEl.value.length;
|
|
3107
|
+
inputEl.setSelectionRange(end, end);
|
|
3108
|
+
};
|
|
3109
|
+
|
|
3110
|
+
// Conversation starter buttons in chat welcome
|
|
3111
|
+
document.getElementById("conversation-starters")?.addEventListener("click", handleConversationStarterClick);
|
|
3112
|
+
|
|
3113
|
+
// Templates icon in input area — switch to Library tab where project assets live
|
|
1666
3114
|
document.getElementById("btn-starter-templates")?.addEventListener("click", () => {
|
|
1667
|
-
const
|
|
1668
|
-
if (
|
|
3115
|
+
const libraryTab = document.getElementById("ws-tab-library");
|
|
3116
|
+
if (libraryTab) {
|
|
3117
|
+
libraryTab.click();
|
|
3118
|
+
document.getElementById("library-project-assets")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
3119
|
+
}
|
|
1669
3120
|
});
|
|
1670
3121
|
|
|
1671
|
-
// Version history
|
|
3122
|
+
// Version history (bottom panel)
|
|
1672
3123
|
document.getElementById("btn-history")?.addEventListener("click", toggleHistoryPanel);
|
|
1673
3124
|
document.getElementById("history-panel-close")?.addEventListener("click", () => {
|
|
1674
3125
|
historyPanelOpen = false;
|
|
1675
3126
|
document.getElementById("history-panel")?.classList.add("hidden");
|
|
1676
3127
|
});
|
|
3128
|
+
document.getElementById("history-panel-collapse")?.addEventListener("click", () => {
|
|
3129
|
+
document.getElementById("history-panel")?.classList.toggle("collapsed");
|
|
3130
|
+
});
|
|
1677
3131
|
|
|
1678
3132
|
// Import from GitHub is now on the setup screen (setup.js)
|
|
1679
3133
|
|
|
@@ -1721,11 +3175,6 @@ document.getElementById("responsive-toggle").addEventListener("click", (e) => {
|
|
|
1721
3175
|
chrome.style.maxWidth = isFullWidth ? "none" : width;
|
|
1722
3176
|
chrome.style.flex = isFullWidth ? "1" : "none";
|
|
1723
3177
|
chrome.style.width = isFullWidth ? "" : width;
|
|
1724
|
-
|
|
1725
|
-
// Update browser URL bar with theme name
|
|
1726
|
-
const urlEl = document.getElementById("browser-url");
|
|
1727
|
-
const themeName = document.getElementById("theme-name")?.textContent || "vibespot.app";
|
|
1728
|
-
if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
|
|
1729
3178
|
});
|
|
1730
3179
|
|
|
1731
3180
|
// ---------------------------------------------------------------------------
|
|
@@ -1742,11 +3191,10 @@ async function fetchHsAccountStatus() {
|
|
|
1742
3191
|
const hs = data.environment?.tools?.hubspot;
|
|
1743
3192
|
|
|
1744
3193
|
if (hs && hs.authenticated && hs.portalName) {
|
|
1745
|
-
|
|
1746
|
-
pill.
|
|
3194
|
+
const idSuffix = hs.portalId ? ` (${hs.portalId})` : "";
|
|
3195
|
+
pill.innerHTML = `<span class="statusbar__dot statusbar__dot--ok"></span><span class="statusbar__item-text">${escapeHtml(hs.portalName)}${escapeHtml(idSuffix)}</span>`;
|
|
1747
3196
|
} else {
|
|
1748
3197
|
pill.textContent = "";
|
|
1749
|
-
pill.classList.remove("statusbar__pill--visible");
|
|
1750
3198
|
}
|
|
1751
3199
|
} catch {
|
|
1752
3200
|
// Silently ignore
|
|
@@ -1792,6 +3240,7 @@ document.getElementById("theme-name")?.addEventListener("dblclick", () => {
|
|
|
1792
3240
|
.then((data) => {
|
|
1793
3241
|
if (data.ok) {
|
|
1794
3242
|
el.textContent = data.newName;
|
|
3243
|
+
setStatusTheme(data.newName);
|
|
1795
3244
|
if (typeof currentAppTheme !== "undefined") currentAppTheme = data.newName;
|
|
1796
3245
|
window.location.hash = "#/app/" + encodeURIComponent(data.newName);
|
|
1797
3246
|
// Update rail item
|