Changeset 0.27.1 (#167)
This commit is contained in:
@@ -78,3 +78,46 @@
|
||||
.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-raised); color: var(--text-2); }
|
||||
.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
|
||||
.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
|
||||
|
||||
/* ── v0.27.0: Workflow stage indicator bar ── */
|
||||
|
||||
.workflow-stage-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-bar-steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.wf-bar-step {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.wf-bar-step.completed { background: var(--success); }
|
||||
.wf-bar-step.current { background: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.wf-bar-stage-name { font-weight: 600; color: var(--text); }
|
||||
.wf-bar-status {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wf-bar-status.active { background: var(--accent-dim); color: var(--accent); }
|
||||
.wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); }
|
||||
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.wf-bar-spacer { flex: 1; }
|
||||
.wf-bar-actions { display: flex; gap: 6px; }
|
||||
.wf-bar-actions .btn-small { font-size: 11px; padding: 3px 10px; }
|
||||
|
||||
/* DnD stage reorder feedback */
|
||||
.wf-stage-row.dragging { opacity: 0.4; }
|
||||
.wf-stage-row.drag-over { border-top: 2px solid var(--accent); }
|
||||
|
||||
@@ -1179,6 +1179,36 @@ function _initChatListeners() {
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
|
||||
Events.on('workflow.advanced', (payload) => {
|
||||
if (!payload) return;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
UI.updateWorkflowStageBar();
|
||||
}
|
||||
// Refresh queue sidebar if present
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow completed — refresh stage bar
|
||||
Events.on('workflow.completed', (payload) => {
|
||||
if (!payload) return;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
UI.updateWorkflowStageBar();
|
||||
}
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow assigned — refresh queue + toast
|
||||
Events.on('workflow.assigned', (payload) => {
|
||||
if (!payload) return;
|
||||
if (payload.assigned_to === (API.user && API.user.id)) {
|
||||
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
|
||||
}
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
@@ -1186,3 +1216,33 @@ function _initChatListeners() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// v0.27.0: Advance workflow stage (called from stage bar button)
|
||||
async function advanceWorkflowStage() {
|
||||
var ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') return;
|
||||
var confirmed = await showConfirm('Advance to the next stage?', { title: 'Advance Workflow', danger: false, ok: 'Advance' });
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/advance', {});
|
||||
UI.toast('Advanced to stage: ' + (resp.stage?.name || resp.current_stage), 'success');
|
||||
UI.updateWorkflowStageBar();
|
||||
} catch (e) {
|
||||
UI.toast('Advance failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.27.0: Reject workflow stage (called from stage bar button)
|
||||
async function rejectWorkflowStage() {
|
||||
var ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') return;
|
||||
var reason = await showPrompt({ title: 'Reject Stage', label: 'Reason for rejection:', placeholder: 'Enter reason...', ok: 'Reject' });
|
||||
if (!reason) return;
|
||||
try {
|
||||
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/reject', { reason: reason });
|
||||
UI.toast('Rejected to stage ' + resp.current_stage + ': ' + reason, 'warning');
|
||||
UI.updateWorkflowStageBar();
|
||||
} catch (e) {
|
||||
UI.toast('Reject failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,48 @@ function _resetPaneSize(el) {
|
||||
|
||||
// ── Persistence ─────────────────────────────
|
||||
|
||||
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
|
||||
var _paneSyncTimer = null;
|
||||
function _syncPaneLayoutsToServer() {
|
||||
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
|
||||
_paneSyncTimer = setTimeout(function() {
|
||||
try {
|
||||
// Collect all sb_layout_* keys
|
||||
var layouts = {};
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (k && k.indexOf('sb_layout_') === 0) {
|
||||
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
|
||||
}
|
||||
}
|
||||
if (typeof API !== 'undefined' && API._put) {
|
||||
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 2000); // 2s debounce — avoid hammering during resize drags
|
||||
}
|
||||
|
||||
// v0.27.0: On page load, restore pane layouts from server if available.
|
||||
// Called once from PaneContainer init (or surface boot).
|
||||
function _loadPaneLayoutsFromServer() {
|
||||
if (typeof API === 'undefined' || !API._get) return;
|
||||
API._get('/api/v1/settings').then(function(settings) {
|
||||
if (!settings || !settings.pane_layouts) return;
|
||||
var layouts = settings.pane_layouts;
|
||||
for (var key in layouts) {
|
||||
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
|
||||
// Only overwrite if localStorage is empty for this key
|
||||
if (!localStorage.getItem(key)) {
|
||||
localStorage.setItem(key, JSON.stringify(layouts[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Kick off server layout load on script execution
|
||||
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
|
||||
|
||||
function _persistSizes(surfaceId, splitEl) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
@@ -456,6 +498,9 @@ function _persistSizes(surfaceId, splitEl) {
|
||||
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
|
||||
Object.assign(existing, sizes);
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
|
||||
// v0.27.0: Debounced sync to server for cross-device persistence
|
||||
_syncPaneLayoutsToServer();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -834,3 +834,61 @@ if (typeof deleteUserPersona === 'undefined') {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
};
|
||||
}
|
||||
|
||||
// v0.27.0: Team Workflows settings section — lists workflows for user's teams.
|
||||
// Loaded as a dynamic section in the settings surface.
|
||||
window.loadTeamWorkflows = async function() {
|
||||
var el = document.getElementById('settingsDynamic');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
var teamsResp = await API.listMyTeams();
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
if (teams.length === 0) {
|
||||
el.innerHTML = '<div class="settings-placeholder">You are not a member of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<p style="font-size:13px;color:var(--text-2);margin-bottom:20px;">' +
|
||||
'Manage workflows owned by your teams. Use the Admin panel for global workflows.</p>';
|
||||
|
||||
for (var t = 0; t < teams.length; t++) {
|
||||
var team = teams[t];
|
||||
var wfResp;
|
||||
try {
|
||||
wfResp = await API.listWorkflows(team.id);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
var workflows = wfResp.data || wfResp || [];
|
||||
|
||||
html += '<div class="settings-section" style="margin-bottom:16px;">' +
|
||||
'<h4 style="margin:0 0 10px;font-size:14px;">' + esc(team.name) + '</h4>';
|
||||
|
||||
if (workflows.length === 0) {
|
||||
html += '<div class="settings-placeholder" style="padding:12px;">No workflows for this team.</div>';
|
||||
} else {
|
||||
workflows.forEach(function(wf) {
|
||||
var statusBadge = wf.is_active ?
|
||||
'<span class="badge badge-ok">Active</span>' :
|
||||
'<span class="badge">Inactive</span>';
|
||||
var base = window.__BASE__ || '';
|
||||
html += '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;' +
|
||||
'background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;margin-bottom:6px;">' +
|
||||
'<span style="flex:1;font-weight:500;">' + esc(wf.name) + '</span>' +
|
||||
statusBadge +
|
||||
'<span class="badge">v' + wf.version + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--text-3);">' + esc(wf.slug) + '</span>' +
|
||||
(API.isAdmin ?
|
||||
'<a href="' + base + '/admin/workflows" class="btn-small" style="text-decoration:none;">Edit in Admin</a>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -823,6 +823,7 @@ const UI = {
|
||||
const ac = App.activeConversation;
|
||||
if (!ac || ac.type === 'direct') {
|
||||
el.style.display = 'none';
|
||||
this.updateWorkflowStageBar();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -852,6 +853,71 @@ const UI = {
|
||||
}
|
||||
el.innerHTML = html;
|
||||
el.style.display = '';
|
||||
this.updateWorkflowStageBar();
|
||||
},
|
||||
// Fetches workflow status and renders stage dots, name, advance/reject buttons.
|
||||
updateWorkflowStageBar() {
|
||||
const el = document.getElementById('workflowStageBar');
|
||||
if (!el) return;
|
||||
|
||||
const ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch status (async, non-blocking — renders on completion)
|
||||
const base = window.__BASE__ || '';
|
||||
API._get(`/api/v1/channels/${ac.id}/workflow/status`).then(ws => {
|
||||
if (!ws || !ws.workflow_id) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache on App for WS event handler access
|
||||
App._workflowStatus = ws;
|
||||
|
||||
// Load stage definitions for name display
|
||||
API._get(`/api/v1/workflows/${ws.workflow_id}`).then(wf => {
|
||||
const stages = wf.stages || [];
|
||||
const current = ws.current_stage || 0;
|
||||
const status = ws.status || 'active';
|
||||
const totalStages = stages.length || 1;
|
||||
const stageName = stages[current] ? stages[current].name : 'Stage ' + current;
|
||||
|
||||
// Step dots
|
||||
let dots = '';
|
||||
for (let i = 0; i < totalStages; i++) {
|
||||
const cls = i < current ? 'completed' : i === current ? 'current' : '';
|
||||
dots += '<div class="wf-bar-step ' + cls + '" title="Stage ' + i + (stages[i] ? ': ' + esc(stages[i].name) : '') + '"></div>';
|
||||
}
|
||||
|
||||
// Status badge
|
||||
const statusCls = status === 'completed' ? 'completed' : status === 'stale' ? 'stale' : 'active';
|
||||
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
// Advance/reject buttons (only for active workflows, admin or team members)
|
||||
let actions = '';
|
||||
if (status === 'active' && API.isAdmin) {
|
||||
actions =
|
||||
'<button class="btn-small btn-primary" onclick="advanceWorkflowStage()">Advance ▸</button>' +
|
||||
(current > 0 ? '<button class="btn-small btn-danger" onclick="rejectWorkflowStage()">◂ Reject</button>' : '');
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="wf-bar-steps">' + dots + '</div>' +
|
||||
'<span class="wf-bar-stage-name">' + esc(stageName) + '</span>' +
|
||||
'<span class="wf-bar-status ' + statusCls + '">' + esc(statusLabel) + '</span>' +
|
||||
'<span class="wf-bar-spacer"></span>' +
|
||||
'<span style="font-size:11px;color:var(--text-3)">Step ' + (current + 1) + ' of ' + totalStages + '</span>' +
|
||||
(actions ? '<div class="wf-bar-actions">' + actions + '</div>' : '');
|
||||
el.style.display = '';
|
||||
}).catch(() => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}).catch(() => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
},
|
||||
|
||||
showEditInline(messageId, currentContent) {
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
|
||||
// v0.27.0: Wire DnD reorder on stage rows
|
||||
_wfWireStageDnD(el, wfId);
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
@@ -267,6 +270,61 @@
|
||||
|
||||
// ── Wire Events (called once per detail open) ──
|
||||
|
||||
// v0.27.0: Wire drag-and-drop reorder on stage rows
|
||||
function _wfWireStageDnD(container, wfId) {
|
||||
var dragSrc = null;
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
dragSrc = row;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
|
||||
r.classList.remove('drag-over');
|
||||
});
|
||||
dragSrc = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (row !== dragSrc) row.classList.add('drag-over');
|
||||
});
|
||||
row.addEventListener('dragleave', function() {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
row.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
row.classList.remove('drag-over');
|
||||
if (!dragSrc || dragSrc === row) return;
|
||||
|
||||
// Reorder in DOM
|
||||
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
|
||||
var fromIdx = rows.indexOf(dragSrc);
|
||||
var toIdx = rows.indexOf(row);
|
||||
if (fromIdx < toIdx) {
|
||||
row.parentNode.insertBefore(dragSrc, row.nextSibling);
|
||||
} else {
|
||||
row.parentNode.insertBefore(dragSrc, row);
|
||||
}
|
||||
|
||||
// Collect new order and send to backend
|
||||
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
|
||||
return r.dataset.id;
|
||||
});
|
||||
API.reorderStages(wfId, orderedIDs).then(function() {
|
||||
UI.toast('Stages reordered', 'success');
|
||||
_wfLoadStages(wfId);
|
||||
}).catch(function(err) {
|
||||
UI.toast('Reorder failed: ' + (err.message || err), 'error');
|
||||
_wfLoadStages(wfId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var _wired = false;
|
||||
function _wfWireEvents() {
|
||||
if (_wired) return;
|
||||
|
||||
Reference in New Issue
Block a user