Changeset 0.25.1 (#161)

This commit is contained in:
2026-03-08 18:54:53 +00:00
parent 2b01d540d6
commit b3f8b747dd
48 changed files with 531 additions and 888 deletions

View File

@@ -24,7 +24,7 @@
}
.admin-surface-row:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.admin-surface-info {

View File

@@ -53,7 +53,7 @@
}
.ch-model-pill-remove:hover {
opacity: 1;
color: var(--error-color, #d32f2f);
color: var(--danger);
}
.ch-model-add-btn {
@@ -151,7 +151,7 @@
.mention-ac-handle {
font-size: 0.72rem;
color: var(--accent, #6c9fff);
font-family: var(--font-mono, monospace);
font-family: var(--mono);
opacity: 0.8;
}
.mention-ac-model {

View File

@@ -164,13 +164,6 @@
color: var(--text-3);
}
.icon-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; border-radius: 4px;
transition: color var(--transition);
}
.icon-btn:hover { color: var(--text); }
/* Messages — Bubble Layout (matching prototype) */
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
@@ -650,9 +643,9 @@
/* ── Chat type indicators (v0.23.0) ────────── */
.chat-type-icon { font-size: 11px; opacity: 0.7; }
.mono-input { font-family: var(--font-mono, 'JetBrains Mono', monospace) !important; font-size: 12px !important; }
.mono-input { font-family: var(--mono) !important; font-size: 12px !important; }
/* Handle hints in dropdowns and pickers */
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 4px; opacity: 0.75; }
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 4px; opacity: 0.75; }

View File

@@ -47,7 +47,7 @@
.editor-topbar-back:hover {
color: var(--text, #eee);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-topbar-sep {
@@ -86,7 +86,7 @@
.editor-ws-selector-btn:hover {
border-color: var(--border, #2a2a2e);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-ws-dropdown {
@@ -128,7 +128,7 @@
}
.editor-ws-dropdown-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-ws-dropdown-item.active {
@@ -247,7 +247,7 @@
.editor-bootstrap-ws-item:hover {
border-color: var(--accent, #b38a4e);
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.editor-bootstrap-ws-name {
@@ -307,7 +307,7 @@
}
.surface-editor .file-tree-row:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.surface-editor .file-tree-row.active {
@@ -361,7 +361,7 @@
}
.file-tree-ctx-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
/* ── CodeEditor overrides ──────────────────── */
@@ -399,7 +399,7 @@
white-space: nowrap;
}
.surface-editor .code-editor-tab:hover { background: var(--bg-tertiary, #1e1e22); }
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }

View File

@@ -16,7 +16,7 @@
flex-direction: column;
align-items: center;
padding: 10px 20px;
background: var(--bg-2);
background: var(--bg-raised);
border-radius: 8px;
min-width: 80px;
}
@@ -93,7 +93,7 @@
/* ── Memory Card ───────────────────────── */
.memory-card {
background: var(--bg-2);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;

View File

@@ -110,7 +110,6 @@
.provider-name { font-weight: 500; font-size: 13px; }
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; }
.provider-actions { display: flex; align-items: center; gap: 8px; }
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
/* Admin panel (fullscreen) */
.admin-panel {
@@ -232,7 +231,6 @@
.admin-persona-row .persona-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.admin-persona-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
.admin-persona-row .btn-delete:hover { color: var(--danger); }
.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); }
/* Stats cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
@@ -247,12 +245,12 @@
.audit-row { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.audit-row:last-child { border-bottom: none; }
.audit-main { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.audit-action { font-weight: 600; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 12px; }
.audit-action { font-weight: 600; color: var(--accent); font-family: var(--mono); font-size: 12px; }
.audit-actor { color: var(--text); }
.audit-resource { color: var(--text-3); font-family: var(--font-mono, monospace); font-size: 11px; }
.audit-resource { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.audit-meta { display: flex; gap: 10px; margin-top: 2px; font-size: 11px; color: var(--text-3); flex-wrap: wrap; }
.audit-details { color: var(--text-2); }
.audit-ip { font-family: var(--font-mono, monospace); }
.audit-ip { font-family: var(--mono); }
.pagination-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
/* Banner preview */
@@ -285,11 +283,6 @@
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
.badge-admin { background: var(--accent); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-pending { background: var(--warning); color: var(--text-on-warning); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-team { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; }
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); }
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
@@ -301,22 +294,12 @@
}
.team-admin-back:hover { opacity: 1; }
.team-tab-content { padding: 0; }
.badge-private { background: var(--purple); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
/* Health + Routing admin (v0.22.3) */
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.admin-health-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.list-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.admin-table { border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
.admin-table th { font-weight: 600; color: var(--text-2); font-size: 11px; text-transform: uppercase; }
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
/* ── Debug Modal ─────────────────────────── */
@@ -455,7 +438,7 @@
.group-persona-item:hover { background: var(--bg-hover); }
.group-persona-item input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
.group-persona-name { flex: 1; color: var(--text); font-weight: 500; }
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; }
/* ── Creation Dialog (project / channel / folder — v0.23.1) ── */
@@ -486,11 +469,3 @@
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.sb-dialog-btn {
padding: 6px 14px; border-radius: var(--radius); font-size: 12px; font-family: inherit;
cursor: pointer; border: 1px solid var(--border);
background: var(--bg-hover); color: var(--text-2);
}
.sb-dialog-btn:hover { background: var(--bg-raised); }
.sb-dialog-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.sb-dialog-btn-primary:hover { opacity: 0.88; }

View File

@@ -10,9 +10,9 @@
.notif-pref-table th {
text-align: left;
padding: 6px 8px;
border-bottom: 2px solid var(--border-color, #ddd);
border-bottom: 2px solid var(--border);
font-size: 0.8rem;
color: var(--text-secondary, #888);
color: var(--text-2);
font-weight: 500;
}
.notif-pref-table th:nth-child(2),
@@ -22,7 +22,7 @@
}
.notif-pref-table td {
padding: 8px;
border-bottom: 1px solid var(--border-color, #eee);
border-bottom: 1px solid var(--border);
}
.notif-pref-toggle {
text-align: center;
@@ -52,10 +52,10 @@
}
.smtp-test-status {
font-size: 0.85rem;
color: var(--text-secondary, #888);
color: var(--text-2);
}
.smtp-test-status.success { color: var(--success-color, #2e7d32); }
.smtp-test-status.error { color: var(--error-color, #d32f2f); }
.smtp-test-status.success { color: var(--success); }
.smtp-test-status.error { color: var(--danger); }
@media (max-width: 600px) {
.smtp-config-grid {

View File

@@ -314,10 +314,6 @@
.notes-select-all input { cursor: pointer; }
.notes-list { padding: 8px; }
.notes-empty, .notes-loading {
text-align: center; color: var(--text-3); padding: 2rem;
font-size: 13px;
}
.note-item {
display: flex; align-items: flex-start; gap: 8px;
@@ -475,7 +471,7 @@
background: var(--bg-surface);
}
.transclusion-header {
padding: 6px 10px; background: var(--bg-2);
padding: 6px 10px; background: var(--bg-raised);
border-bottom: 1px solid var(--border); font-size: 12px;
}
.transclusion-content {

View File

@@ -13,7 +13,7 @@
.persona-kb-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #333);
border: 1px solid var(--border);
border-radius: var(--radius, 6px);
padding: 0.25rem;
}

View File

@@ -41,14 +41,6 @@ input[type="password"]:focus, textarea:focus {
}
select option { background: var(--bg-surface); color: var(--text); }
.form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea {
width: 100%;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 0.75rem; }
.form-row .form-group { flex: 1; }
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
@@ -68,25 +60,6 @@ select option { background: var(--bg-surface); color: var(--text); }
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); }
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Theme Toggle ────────────────────────── */
.theme-toggle {
display: flex; gap: 4px; padding: 3px;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); width: fit-content;
}
.theme-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: calc(var(--radius) - 2px);
border: none; background: none; color: var(--text-3);
font-family: var(--font); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all var(--transition);
}
.theme-btn:hover { color: var(--text); background: var(--bg-hover); }
.theme-btn.active {
color: var(--text); background: var(--bg-raised);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
/* ── Toast ────────────────────────────────── */
@@ -320,9 +293,6 @@ select option { background: var(--bg-surface); color: var(--text); }
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.kb-popup-meta { font-size: 11px; color: var(--text-3); }
.kb-popup-empty, .kb-popup-loading {
padding: 16px; text-align: center; font-size: 13px; color: var(--text-3);
}
/* KB management panel (settings tab) */
.kb-manage-header {
@@ -335,7 +305,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.kb-manage-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--radius);
background: var(--bg-2); transition: background var(--transition);
background: var(--bg-raised); transition: background var(--transition);
}
.kb-manage-item:hover { background: var(--bg-hover); }
.kb-manage-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
@@ -344,9 +314,6 @@ select option { background: var(--bg-surface); color: var(--text); }
.kb-manage-stats { font-size: 11px; color: var(--text-3); }
.kb-manage-desc-block { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
.kb-manage-actions { display: flex; gap: 4px; flex-shrink: 0; }
.kb-manage-empty, .kb-manage-loading {
padding: 20px; text-align: center; font-size: 13px; color: var(--text-3);
}
.kb-doc-item[data-doc-status="pending"] .kb-manage-name,
.kb-doc-item[data-doc-status="extracting"] .kb-manage-name,
.kb-doc-item[data-doc-status="chunking"] .kb-manage-name,
@@ -363,20 +330,15 @@ select option { background: var(--bg-surface); color: var(--text); }
color: var(--text-2); margin-bottom: 5px;
}
.form-group input, .form-group select, .form-group textarea {
padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg, var(--bg)); color: var(--text);
font-size: 13px; font-family: inherit;
width: 100%; max-width: 360px; outline: none;
transition: border-color 0.15s;
width: 100%; outline: none;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--accent);
}
.form-group input:disabled { opacity: 0.6; }
.form-group input[type="range"] { max-width: 100%; border: none; background: none; }
.form-group input[type="range"] { border: none; background: none; }
.form-group input[type="range"]:focus { box-shadow: none; }
.form-group input[type="checkbox"] { width: auto; max-width: none; accent-color: var(--accent); }
.form-group input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.toggle-group {
display: flex; gap: 1px; padding: 3px;
@@ -392,26 +354,32 @@ select option { background: var(--bg-surface); color: var(--text); }
.toggle-btn.active { background: var(--bg-raised); color: var(--text); }
.toggle-btn:hover { color: var(--text-2); }
.btn-primary { background: var(--accent); color: var(--text-on-color); border: none; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; }
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg-hover); }
.btn-subtle { background: var(--accent-dim); color: var(--accent); border: none; }
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 500; font-family: inherit; }
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: 8px; cursor: pointer; font-weight: 500; font-family: inherit; }
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: var(--radius); cursor: pointer; font-weight: 500; font-family: inherit; }
/* ── Badge ───────────────────────────────── */
.badge {
font-size: 10px; padding: 2px 7px; border-radius: 4px;
font-size: 10px; padding: 2px 8px; border-radius: 4px;
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
display: inline-flex; align-items: center;
}
.badge-accent { background: var(--accent-dim); color: var(--accent); }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-danger { background: var(--danger-dim); color: var(--danger); }
.badge-muted { background: var(--bg-raised); color: var(--text-3); }
/* Dim variants (colored text on tinted background) */
.badge-accent, .badge-models, .badge-team { background: var(--accent-dim); color: var(--accent-light); }
.badge-success, .badge-active, .badge-healthy { background: var(--success-dim); color: var(--success-light); }
.badge-warning, .badge-degraded { background: var(--warning-dim); color: var(--warning-light); }
.badge-danger, .badge-disabled, .badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); }
.badge-muted, .badge-unknown, .badge-provider-type, .badge-global, .badge-user { background: var(--bg-raised); color: var(--text-3); }
.badge-purple, .badge-service { background: var(--purple-dim); color: var(--purple); }
/* Solid variants (white text on colored background) */
.badge-admin { background: var(--accent); color: var(--text-on-color); }
.badge-inactive { background: var(--danger); color: var(--text-on-color); }
.badge-pending { background: var(--warning); color: var(--text-on-warning); }
.badge-private { background: var(--purple); color: var(--text-on-color); }
/* ── Status Dot ──────────────────────────── */
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
@@ -429,4 +397,10 @@ select option { background: var(--bg-surface); color: var(--text); }
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text-2); }
.icon-btn.active { color: var(--accent); }
.icon-btn-danger:hover { color: var(--danger); }
/* ── Utility States ─────────────────────── */
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }

View File

@@ -122,7 +122,7 @@
.project-panel-label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; }
.project-panel-textarea {
width: 100%; resize: vertical; min-height: 80px; padding: 8px;
font-family: var(--font-mono, monospace); font-size: 13px;
font-family: var(--mono); font-size: 13px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text);
}
@@ -429,7 +429,7 @@
/* ── Bar Chart ───────────────────────────── */
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; }
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--font-mono, monospace); }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; }
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
.bar-chart-label { font-size: 10px; color: var(--text-3); }
@@ -484,18 +484,8 @@
}
.admin-user-name { font-weight: 600; font-size: 13px; }
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--font-mono, monospace); }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
.admin-actions-cell { white-space: nowrap; text-align: right; }
.btn-icon {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; font-size: 14px; border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
.btn-icon-danger:hover { color: var(--danger); }
.badge-service { background: var(--purple-dim); color: var(--purple); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-disabled { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-active { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
/* ── Provider Cards ──────────────────────── */
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
@@ -507,12 +497,6 @@
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.provider-card-name { font-weight: 600; font-size: 14px; }
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
.badge-healthy { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-degraded { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unknown { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-models { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-provider-type { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
@media (max-width: 768px) {
@@ -775,3 +759,13 @@
/* Indented chat items inside folders */
.chat-item.indented { padding-left: 28px; }
/* ── Notes Surface ─────────────────────── */
.surface-notes { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.surface-notes-body { flex: 1; display: flex; min-height: 0; overflow: hidden; }
.surface-notes-main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.surface-notes-assist {
width: 400px; border-left: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
@media (max-width: 768px) { .surface-notes-assist { display: none; } }

View File

@@ -33,7 +33,7 @@
}
.tool-grants-item:hover {
background: var(--bg-tertiary, #1e1e22);
background: var(--bg-hover);
}
.tool-grants-item input[type="checkbox"] {

View File

@@ -474,13 +474,6 @@ function _initAdminListeners() {
document.querySelectorAll('.admin-cat').forEach(btn => {
btn.addEventListener('click', () => UI.switchAdminCategory(btn.dataset.cat));
});
// Esc closes admin panel
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('adminPanel')?.style.display === 'flex') {
UI.closeAdmin();
e.stopPropagation();
}
});
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users

View File

@@ -120,22 +120,22 @@ async function _loadSurfaceList() {
row.innerHTML =
'<div class="admin-surface-info">' +
'<div class="admin-surface-title">' +
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
'<span class="admin-surface-name">' + esc(s.title) + '</span>' +
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
'</div>' +
'<div class="admin-surface-meta">' +
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
'<span class="admin-surface-id">' + esc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + esc(route) + '</span>' : '') +
'</div>' +
'</div>' +
'<div class="admin-surface-actions">' +
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
' data-surface-id="' + _surfEsc(s.id) + '">' +
' data-surface-id="' + esc(s.id) + '">' +
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
'</label>' +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + esc(s.id) + '">Uninstall</button>' : '') +
'</div>';
// Toggle handler
@@ -187,12 +187,7 @@ async function _loadSurfaceList() {
listEl.appendChild(row);
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + esc(e.message) + '</div>';
}
}
function _surfEsc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}

View File

@@ -6,12 +6,11 @@
// ChatPane.primary is the backward-compat bridge.
const ChatPane = {
primary: null,
_instances: new Map(),
...createComponentRegistry('ChatPane'),
create(opts) {
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const instance = {
const instance = componentMixin({
id,
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
@@ -22,7 +21,6 @@ const ChatPane = {
standalone: opts.standalone || false,
_cmView: null,
_typing: null,
_listeners: [],
renderMessages(messages) {
if (!this.messagesEl) return;
@@ -34,7 +32,7 @@ const ChatPane = {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
div.innerHTML = typeof formatMessage === 'function'
? formatMessage(m) : _cpEsc(m.content || '');
? formatMessage(m) : esc(m.content || '');
this.messagesEl.appendChild(div);
});
}
@@ -127,28 +125,16 @@ const ChatPane = {
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
},
on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
this.removeTyping();
ChatPane._instances.delete(this.id);
if (ChatPane.primary === this) ChatPane.primary = null;
},
};
}, ChatPane);
ChatPane._instances.set(id, instance);
ChatPane._register(id, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
forChannel(channelId) {
for (const [, inst] of this._instances) {
if (inst.channelId === channelId) return inst;
@@ -159,4 +145,3 @@ const ChatPane = {
active() { return this.primary; },
};
function _cpEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -19,11 +19,11 @@
// editor.destroy();
const CodeEditor = {
_instances: new Map(),
...createComponentRegistry('CodeEditor'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
const instance = componentMixin({
id: pfx,
tabsEl: document.getElementById(pfx + 'EditorTabs'),
contentEl: document.getElementById(pfx + 'EditorContent'),
@@ -35,9 +35,8 @@ const CodeEditor = {
workspaceId: opts.workspaceId || null,
onSave: opts.onSave || null,
onActivate: opts.onActivate || null,
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
_openFiles: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
@@ -69,7 +68,7 @@ const CodeEditor = {
const fileName = path.split('/').pop();
tab.innerHTML =
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + esc(fileName) + '</span>' +
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
'<button class="code-editor-tab-close" title="Close">✕</button>';
@@ -235,10 +234,7 @@ const CodeEditor = {
});
},
// ── Lifecycle ───────────────────────
destroy() {
// Destroy all CM6 instances
_cleanup() {
for (const [, f] of this._openFiles) {
if (f.editor?.destroy) f.editor.destroy();
f.tab.remove();
@@ -246,9 +242,6 @@ const CodeEditor = {
}
this._openFiles.clear();
this._activeFile = null;
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
CodeEditor._instances.delete(this.id);
},
// ── Internal ────────────────────────
@@ -288,23 +281,15 @@ const CodeEditor = {
return Promise.resolve(true);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
}, CodeEditor);
CodeEditor._instances.set(pfx, instance);
CodeEditor._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ceFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();

View File

@@ -603,7 +603,7 @@ function copyDebugLog() {
const text = DebugLog.exportText();
navigator.clipboard.writeText(text)
.then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); })
.catch(() => { alert('Failed to copy'); });
.catch(() => { UI.toast('Copy failed', 'error'); });
}
function exportDebugLog() {

View File

@@ -162,8 +162,8 @@
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
@@ -336,7 +336,6 @@
// ── Helpers ──────────────────────────────
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── Assist Chat (channel-based, standalone) ──
// Full chat pane for the editor: model selector, chat history,
@@ -616,7 +615,7 @@
function _appendSystemMsg(text) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--system';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + esc(text) + '</div>';
chatPane.messagesEl?.appendChild(div);
chatPane.scrollToBottom();
}

View File

@@ -18,11 +18,11 @@
// tree.destroy();
const FileTree = {
_instances: new Map(),
...createComponentRegistry('FileTree'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
const instance = componentMixin({
id: pfx,
itemsEl: document.getElementById(pfx + 'TreeItems'),
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
@@ -34,7 +34,6 @@ const FileTree = {
_expandedDirs: new Set(['']),
_gitStatusMap: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
@@ -166,7 +165,7 @@ const FileTree = {
row.innerHTML =
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
'<span class="file-tree-icon">📁</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self._expandedDirs.has(node.path)) {
self._expandedDirs.delete(node.path);
@@ -182,7 +181,7 @@ const FileTree = {
} else {
row.innerHTML =
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self.onSelect) self.onSelect(node.path);
});
@@ -233,34 +232,20 @@ const FileTree = {
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
_cleanup() {
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
FileTree._instances.delete(this.id);
},
}, FileTree);
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
FileTree._instances.set(pfx, instance);
FileTree._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ftFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();

View File

@@ -344,8 +344,8 @@ function _renderStrip() {
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${_esc(a.filename)}">${_esc(name)}</span>
<span class="att-meta">${_esc(size)}${label ? ' · ' + label : ''}</span>
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button>
</div>`;
@@ -363,12 +363,6 @@ function _truncFilename(name, max) {
return name.slice(0, max - 1) + '…';
}
function _esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Channel File Loading ──────────────
@@ -426,8 +420,8 @@ function renderMessageFiles(msgId) {
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" onclick="openLightbox('${_esc(att.id)}')">
<img class="msg-att-img" data-att-id="${_esc(att.id)}" alt="${_esc(att.filename)}"
return `<div class="msg-att msg-att-image" onclick="openLightbox('${esc(att.id)}')">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
@@ -436,10 +430,10 @@ function renderMessageFiles(msgId) {
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${_esc(att.id)}','${_esc(att.filename)}');return false">
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${esc(att.id)}','${esc(att.filename)}');return false">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${_esc(att.filename)}</span>
<span class="att-doc-name">${esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
@@ -565,7 +559,7 @@ async function loadAdminStorage() {
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${_esc(status.backend || 'none')}</span>
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
@@ -581,15 +575,15 @@ async function loadAdminStorage() {
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.path)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.endpoint)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.bucket)}</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
@@ -640,7 +634,7 @@ async function loadAdminStorage() {
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${_esc(e.message)}</div>`;
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
}
}

View File

@@ -29,12 +29,12 @@ const KnowledgeUI = (() => {
if (!popup) return;
if (!App.activeId) {
popup.innerHTML = '<div class="kb-popup-empty">Start a conversation first</div>';
popup.innerHTML = '<div class="empty-hint">Start a conversation first</div>';
popup.classList.add('open');
return;
}
popup.innerHTML = '<div class="kb-popup-loading">Loading…</div>';
popup.innerHTML = '<div class="loading">Loading…</div>';
popup.classList.add('open');
try {
@@ -46,7 +46,7 @@ const KnowledgeUI = (() => {
_channelKBs = chResp.data || [];
_renderChannelPopup(popup);
} catch (e) {
popup.innerHTML = '<div class="kb-popup-empty">Failed to load knowledge bases</div>';
popup.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
console.warn('KB popup load failed:', e);
}
}
@@ -54,7 +54,7 @@ const KnowledgeUI = (() => {
function _renderChannelPopup(popup) {
if (_allKBs.length === 0) {
popup.innerHTML = `
<div class="kb-popup-empty">
<div class="empty-hint">
No knowledge bases yet.
<br><small>Create one in Settings → Knowledge Bases</small>
</div>`;
@@ -73,7 +73,7 @@ const KnowledgeUI = (() => {
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
<span class="kb-popup-check"></span>
<span class="kb-popup-label">
<span class="kb-popup-name">${_esc(kb.name)}</span>
<span class="kb-popup-name">${esc(kb.name)}</span>
<span class="kb-popup-meta">${docs}${scope}${status}</span>
</span>
</div>`;
@@ -130,7 +130,7 @@ const KnowledgeUI = (() => {
}
_panelCtx = { container: containerId, scope, teamId };
panel.innerHTML = '<div class="kb-manage-loading">Loading…</div>';
panel.innerHTML = '<div class="loading">Loading…</div>';
panel.style.display = '';
try {
@@ -146,7 +146,7 @@ const KnowledgeUI = (() => {
_renderManagePanel(panel);
} catch (e) {
console.warn('[KB] openPanel error:', e);
panel.innerHTML = '<div class="kb-manage-empty">Failed to load knowledge bases</div>';
panel.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
}
}
@@ -166,7 +166,7 @@ const KnowledgeUI = (() => {
</div>`;
if (_allKBs.length === 0) {
html += '<div class="kb-manage-empty">No knowledge bases yet. Create one to get started.</div>';
html += '<div class="empty-hint">No knowledge bases yet. Create one to get started.</div>';
} else {
// Summary stats
const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0);
@@ -184,12 +184,12 @@ const KnowledgeUI = (() => {
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
<span class="kb-manage-name">${scope} ${esc(kb.name)}</span>
<span class="kb-manage-desc">${esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${_esc(kb.name)}" title="Access">🔒</button>` : ''}
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
@@ -273,7 +273,7 @@ const KnowledgeUI = (() => {
close();
_refreshCurrentPanel();
} catch (e) {
alert('Failed to create: ' + (e.message || e));
UI.toast('Failed to create: ' + (e.message || e), 'error');
}
});
@@ -292,12 +292,12 @@ const KnowledgeUI = (() => {
async function _deleteKB(kbId) {
const kb = _allKBs.find(k => k.id === kbId);
if (!confirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
if (!await showConfirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
try {
await API.deleteKnowledgeBase(kbId);
_refreshCurrentPanel();
} catch (e) {
alert('Delete failed: ' + (e.message || e));
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
@@ -322,7 +322,7 @@ const KnowledgeUI = (() => {
const panel = document.getElementById(containerId);
if (!panel) return;
panel.innerHTML = '<div class="kb-manage-loading">Loading documents…</div>';
panel.innerHTML = '<div class="loading">Loading documents…</div>';
try {
const [kbResp, docsResp] = await Promise.all([
@@ -333,7 +333,7 @@ const KnowledgeUI = (() => {
const docs = docsResp.data || docsResp || [];
_renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []);
} catch (e) {
panel.innerHTML = `<div class="kb-manage-empty">Failed to load documents: ${_esc(e.message || '')}</div>`;
panel.innerHTML = `<div class="empty-hint">Failed to load documents: ${esc(e.message || '')}</div>`;
}
}
@@ -341,7 +341,7 @@ const KnowledgeUI = (() => {
let html = `
<div class="kb-manage-header">
<button class="btn-small" id="kbDocsBack">← Back</button>
<h3>${_esc(kb.name)}</h3>
<h3>${esc(kb.name)}</h3>
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
+ Upload
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
@@ -349,7 +349,7 @@ const KnowledgeUI = (() => {
</div>`;
if (kb.description) {
html += `<div class="kb-manage-desc-block">${_esc(kb.description)}</div>`;
html += `<div class="kb-manage-desc-block">${esc(kb.description)}</div>`;
}
// Stats line
@@ -360,18 +360,18 @@ const KnowledgeUI = (() => {
}
if (docs.length === 0) {
html += '<div class="kb-manage-empty">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
html += '<div class="empty-hint">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
} else {
html += '<div class="kb-manage-list">';
for (const doc of docs) {
const statusIcon = _docStatusIcon(doc.status);
const size = _formatSize(doc.size_bytes);
const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : '';
const errText = doc.error ? ` · ⚠ ${_esc(doc.error)}` : '';
const errText = doc.error ? ` · ⚠ ${esc(doc.error)}` : '';
html += `
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
<div class="kb-manage-info">
<span class="kb-manage-name">${statusIcon} ${_esc(doc.filename)}</span>
<span class="kb-manage-name">${statusIcon} ${esc(doc.filename)}</span>
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
</div>
<div class="kb-manage-actions">
@@ -417,18 +417,18 @@ const KnowledgeUI = (() => {
console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`);
if (resp.id) _startPolling(kbId, resp.id);
} catch (e) {
alert(`Upload failed for ${file.name}: ${e.message || e}`);
UI.toast(`Upload failed for ${file.name}: ${e.message || e}`, 'error');
}
}
async function _deleteDocument(kbId, docId) {
if (!confirm('Delete this document and all its chunks?')) return;
if (!await showConfirm('Delete this document and all its chunks?')) return;
try {
_stopPolling(docId);
await API.deleteKBDocument(kbId, docId);
openDocumentsPanel(kbId);
} catch (e) {
alert('Delete failed: ' + (e.message || e));
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
@@ -467,7 +467,7 @@ const KnowledgeUI = (() => {
if (nameEl) {
const icon = _docStatusIcon(status.status);
const filename = nameEl.textContent.replace(/^[^\s]+ /, '');
nameEl.innerHTML = `${icon} ${_esc(status.filename || filename)}`;
nameEl.innerHTML = `${icon} ${esc(status.filename || filename)}`;
}
const statsEl = item.querySelector('.kb-manage-stats');
if (statsEl && status.chunk_count > 0) {
@@ -538,12 +538,6 @@ const KnowledgeUI = (() => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function _esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═════════════════════════════════════════
// Public API

View File

@@ -160,7 +160,7 @@ const MemoryUI = {
},
async deleteMemory(id) {
if (!confirm('Delete this memory permanently?')) return;
if (!await showConfirm('Delete this memory permanently?')) return;
try {
await API.deleteMemory(id);
UI.toast('Memory deleted', 'success');
@@ -213,7 +213,7 @@ const MemoryUI = {
const data = await API.listMemories('pending_review', '');
const ids = (data.memories || []).map(m => m.id);
if (!ids.length) return UI.toast('No pending memories', 'info');
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
this.openSettingsPanel();
@@ -250,7 +250,7 @@ const MemoryUI = {
// Wire events
document.getElementById('adminBulkApproveBtn')?.addEventListener('click', async () => {
const ids = pending.map(m => m.id);
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
try {
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');

View File

@@ -13,12 +13,11 @@
// sel.destroy();
const ModelSelector = {
primary: null,
_instances: new Map(),
...createComponentRegistry('ModelSelector'),
create(opts) {
const pfx = opts.id || '';
const instance = {
const instance = componentMixin({
id: pfx,
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
@@ -28,7 +27,6 @@ const ModelSelector = {
onChange: opts.onChange || null,
_value: '',
_models: [],
_listeners: [],
// ── Selection ───────────────────────
@@ -100,11 +98,11 @@ const ModelSelector = {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
const avatar = m.personaAvatar ? '<img src="' + esc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + esc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + esc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + esc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + esc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
div.addEventListener('click', () => {
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
self.close();
@@ -163,29 +161,9 @@ const ModelSelector = {
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
});
},
}, ModelSelector);
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
ModelSelector._instances.delete(this.id);
if (ModelSelector.primary === this) ModelSelector.primary = null;
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
ModelSelector._instances.set(pfx, instance);
ModelSelector._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// HTML-escape for model selector content
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -20,11 +20,11 @@
// (editor pane, future notes-studio layout).
const NoteEditor = {
_instances: new Map(),
...createComponentRegistry('NoteEditor'),
create(opts) {
const pfx = opts.id || 'main';
const instance = {
const instance = componentMixin({
id: pfx,
rootEl: document.getElementById(pfx + 'NoteEditor'),
listViewEl: document.getElementById(pfx + 'NotesListView'),
@@ -51,7 +51,6 @@ const NoteEditor = {
_currentNote: null,
_sort: 'updated_desc',
_cmEditor: null,
_listeners: [],
// ── View Switching ───────────────────
@@ -78,7 +77,7 @@ const NoteEditor = {
async loadNotes(folder, searchQuery) {
if (!this.listEl) return;
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
this.listEl.innerHTML = '<div class="loading">Loading…</div>';
try {
let notes;
@@ -92,7 +91,7 @@ const NoteEditor = {
}
if (notes.length === 0) {
this.listEl.innerHTML = '<div class="notes-empty">' +
this.listEl.innerHTML = '<div class="empty-hint">' +
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
return;
}
@@ -108,15 +107,15 @@ const NoteEditor = {
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
item.innerHTML =
'<div class="note-content-col">' +
'<div class="note-title">' + _neEsc(title) + '</div>' +
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
'<div class="note-date">' + _neEsc(date) + '</div>' +
'<div class="note-title">' + esc(title) + '</div>' +
'<div class="note-preview">' + esc(preview) + '</div>' +
'<div class="note-date">' + esc(date) + '</div>' +
'</div>';
item.addEventListener('click', () => self.openNote(note.id));
self.listEl.appendChild(item);
});
} catch (e) {
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
this.listEl.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
},
@@ -422,29 +421,15 @@ const NoteEditor = {
}
},
// ── Lifecycle ───────────────────────
destroy() {
_cleanup() {
this._destroyCmEditor();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._editingNoteId = null;
this._currentNote = null;
NoteEditor._instances.delete(this.id);
},
}, NoteEditor);
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
NoteEditor._instances.set(pfx, instance);
NoteEditor._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -25,7 +25,7 @@ async function openNotes() {
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="notes-loading">Loading…</div>';
list.innerHTML = '<div class="loading">Loading…</div>';
try {
let data;
@@ -33,7 +33,7 @@ async function loadNotesList(folder, searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="notes-empty">No results found</div>';
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
@@ -42,13 +42,13 @@ async function loadNotesList(folder, searchQuery) {
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
}
@@ -177,7 +177,7 @@ function showNotesList() {
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
if (list) list.innerHTML = '<div class="loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode

View File

@@ -126,8 +126,8 @@ const Notifications = {
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onItemClick(this)">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
</div>`;
@@ -277,8 +277,8 @@ const Notifications = {
<div class="notif-panel-row">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
<button class="notif-delete" onclick="event.stopPropagation(); Notifications._deleteItem('${n.id}')" title="Delete">✕</button>
@@ -411,12 +411,6 @@ const Notifications = {
// ── Helpers ──────────────────────────────────
function _esc(s) {
if (!s) return '';
const el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
function _timeAgo(isoStr) {
if (!isoStr) return '';

View File

@@ -29,7 +29,7 @@ const Pages = {
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${_esc(m.display_name || m.model_id)}</option>`
`<option value="${m.model_id}">${esc(m.display_name || m.model_id)}</option>`
).join('');
},
@@ -302,11 +302,6 @@ function _val(id, setVal) {
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {

View File

@@ -271,7 +271,7 @@ const PanelRegistry = {
const panel = this._panels[this._active];
slot.innerHTML = (panel.actions || [])
.filter(a => !a.visible || a.visible())
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${_escPanel(a.title || '')}">${a.icon || ''}</button>`)
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${esc(a.title || '')}">${a.icon || ''}</button>`)
.join('');
},
@@ -282,10 +282,6 @@ const PanelRegistry = {
};
/** Minimal HTML escaping for panel labels/titles. */
function _escPanel(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Secondary Pane Fullscreen ──────────────

View File

@@ -68,8 +68,8 @@ function _showCreationDialog({ title, placeholder, withColor = false, defaultCol
<input id="sbDialogColor" type="color" value="${defaultColor}" class="sb-dialog-color-input">
</div>` : ''}
<div class="sb-dialog-actions">
<button id="sbDialogCancel" class="sb-dialog-btn">Cancel</button>
<button id="sbDialogOK" class="sb-dialog-btn sb-dialog-btn-primary">${btnLabel}</button>
<button id="sbDialogCancel" class="btn-small">Cancel</button>
<button id="sbDialogOK" class="btn-small btn-primary">${btnLabel}</button>
</div>
</div>`;
@@ -142,7 +142,7 @@ async function renameProject(projectId) {
async function deleteProject(projectId) {
const proj = App.projects.find(p => p.id === projectId);
if (!proj) return;
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
if (!await showConfirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
try {
await API.deleteProject(projectId);
App.projects = App.projects.filter(p => p.id !== projectId);

View File

@@ -465,13 +465,17 @@ function _renderCmdResults(query) {
// ── Settings Listeners (extracted from initListeners) ──
function _initSettingsListeners() {
// Settings modal
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
// Settings modal elements only exist on the settings surface.
// On chat surface, openSettings() navigates to /settings/ instead.
const settingsModel = document.getElementById('settingsModel');
if (!settingsModel) return;
document.getElementById('settingsCloseBtn')?.addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn')?.addEventListener('click', handleSaveSettings);
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() {
settingsModel.addEventListener('change', function() {
const model = App.findModel(this.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');

View File

@@ -1,43 +0,0 @@
/**
* surface-nav.js — Navigation overrides for server-rendered surfaces.
*
* Replaces SPA modal-based openSettings/openAdmin with page navigation
* to the dedicated /settings and /admin surface routes.
*
* Load AFTER ui-core.js and ui-admin.js so it overrides their methods.
*
* v0.22.8 — FE refactor: surfaces replace modals
*/
(function() {
'use strict';
const base = window.__BASE__ || '';
// ── Settings: navigate to /settings surface ──────────
if (typeof UI !== 'undefined') {
UI.openSettings = function(section) {
const sec = section || 'general';
window.location.href = base + '/settings/' + sec;
};
UI.closeSettings = function() {
// Navigate back to chat if called from settings surface
window.location.href = base + '/';
};
}
// ── Admin: navigate to /admin surface ────────────────
if (typeof UI !== 'undefined') {
UI.openAdmin = function(cat, section) {
const sec = section || 'users';
window.location.href = base + '/admin/' + sec;
};
UI.closeAdmin = function() {
window.location.href = base + '/';
};
UI.openAdminSection = function(section) {
window.location.href = base + '/admin/' + (section || 'users');
};
}
console.log('[surface-nav] Navigation overrides active — settings/admin use server surfaces');
})();

View File

@@ -66,26 +66,19 @@ Object.assign(UI, {
// ── Open / Close ──────────────────────────
openAdmin(cat, section) {
UI._adminCat = cat || 'people';
UI._adminSection = section || ADMIN_SECTIONS[UI._adminCat][0];
const panel = document.getElementById('adminPanel');
panel.style.display = 'flex';
// Show current user email
const userEl = document.getElementById('adminCurrentUser');
if (userEl && API.user) userEl.textContent = API.user.email || API.user.username || '';
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
const base = window.__BASE__ || '';
window.location.href = base + '/admin/' + (section || 'users');
},
closeAdmin() {
document.getElementById('adminPanel').style.display = 'none';
const base = window.__BASE__ || '';
window.location.href = base + '/';
},
// ── Navigate to specific section ──────────
// Called from command palette, deep links, etc.
openAdminSection(section) {
const cat = _adminCatForSection(section);
UI.openAdmin(cat, section);
const base = window.__BASE__ || '';
window.location.href = base + '/admin/' + (section || 'users');
},
// ── Category switch ───────────────────────
@@ -128,21 +121,6 @@ Object.assign(UI, {
await ADMIN_LOADERS[section]?.();
},
// ── Backward compat: switchAdminTab still works ──
// (called by some inline onclick handlers and admin-handlers.js)
async switchAdminTab(tab) {
const cat = _adminCatForSection(tab);
if (document.getElementById('adminPanel').style.display !== 'flex') {
UI.openAdmin(cat, tab);
} else {
if (cat !== UI._adminCat) {
UI._adminCat = cat;
UI._renderAdminNav();
}
await UI._switchAdminSection(tab);
}
},
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
@@ -233,12 +211,12 @@ Object.assign(UI, {
<td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell">
${isPending
? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="btn-icon" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
? `<button class="icon-btn" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="icon-btn" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
}
<button class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="icon-btn" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="icon-btn icon-btn-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('');
@@ -439,9 +417,9 @@ Object.assign(UI, {
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
<button class="icon-btn" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -746,10 +724,10 @@ Object.assign(UI, {
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="btn-icon" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="icon-btn" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="icon-btn" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -788,9 +766,9 @@ Object.assign(UI, {
</td>
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="btn-icon" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="icon-btn" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -843,7 +821,7 @@ Object.assign(UI, {
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -898,8 +876,8 @@ Object.assign(UI, {
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="btn-icon btn-icon-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="icon-btn icon-btn-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
@@ -1022,7 +1000,7 @@ Object.assign(UI, {
</td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
@@ -1196,9 +1174,9 @@ Object.assign(UI, {
<td class="admin-user-time">${p.priority}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
<button class="icon-btn" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="icon-btn" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="icon-btn icon-btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>

View File

@@ -1377,34 +1377,14 @@ const UI = {
// ── Settings Modal ───────────────────────
openSettings() {
document.getElementById('settingsModel').value = App.settings.model;
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
document.getElementById('settingsTemp').value = App.settings.temperature;
document.getElementById('settingsThinking').checked = App.settings.showThinking;
document.getElementById('profileChangePwForm').style.display = 'none';
// Show model's max output in the hint
const caps = this.getSelectedModelCaps();
const hint = document.getElementById('settingsMaxHint');
if (hint) {
if (caps.max_output_tokens > 0) {
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
hint.textContent = `(model max: ${k})`;
} else {
hint.textContent = '';
}
}
UI.loadProfileIntoSettings();
UI.loadMyTeams();
UI.checkUserProvidersAllowed();
UI.checkUserPersonasAllowed();
UI.switchSettingsTab('general');
openModal('settingsModal');
openSettings(section) {
const base = window.__BASE__ || '';
window.location.href = base + '/settings/' + (section || 'general');
},
closeSettings() {
const base = window.__BASE__ || '';
window.location.href = base + '/';
},
closeSettings() { closeModal('settingsModal'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));

View File

@@ -1,17 +1,8 @@
// ==========================================
// Chat Switchboard UI Formatting & Helpers
// ==========================================
// Pure utility layer: esc(), markdown rendering, code blocks,
// preview panel, time formatting. Loaded after panels.js.
// ── HTML Escaping ───────────────────────────
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Markdown rendering, code blocks, preview panel, time formatting.
// esc() is in ui-primitives.js (loaded globally in base.html).
// ── Message Formatting ──────────────────────

View File

@@ -1,199 +0,0 @@
// ==========================================
// Chat Switchboard - UI Primitives Additions
// ==========================================
// v0.22.7: Toast, Badge, IconBtn, Theme toggle, confirm dialog, avatar upload.
// Load AFTER ui-primitives.js in base.html.
// -- Toast ------------------------------------------------
var Toast = {
_container: null,
_init: function() {
if (this._container) return;
this._container = document.getElementById('toastContainer');
if (!this._container) {
this._container = document.createElement('div');
this._container.id = 'toastContainer';
this._container.className = 'toast-stack';
document.body.appendChild(this._container);
}
},
show: function(msg, type) {
type = type || 'info';
this._init();
var el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = '<span class="toast-icon"></span><span class="toast-msg">' + (typeof esc === 'function' ? esc(msg) : msg) + '</span>';
this._container.appendChild(el);
setTimeout(function() {
el.classList.add('toast-exit');
setTimeout(function() { el.remove(); }, 300);
}, 3000);
},
success: function(msg) { this.show(msg, 'success'); },
error: function(msg) { this.show(msg, 'error'); },
warning: function(msg) { this.show(msg, 'warning'); },
info: function(msg) { this.show(msg, 'info'); },
};
// -- Badge ------------------------------------------------
function renderBadge(text, color) {
color = color || 'muted';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<span class="badge badge-' + e(color) + '">' + e(text) + '</span>';
}
// -- Icon SVG ---------------------------------------------
var _ICONS = {
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
back: '<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
send: '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>',
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 010-7.777L12 4l3.5 3.5L18 5l3-3"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.2.65.7 1.15 1.33 1.34H21a2 2 0 010 4h-.09c-.63.2-1.13.7-1.33 1.34z"/>',
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
msg: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
};
function renderIcon(name, size) {
size = size || 16;
var d = _ICONS[name];
if (!d) return '';
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
}
function renderIconBtn(icon, opts) {
opts = opts || {};
var title = opts.title || '';
var onclick = opts.onclick || '';
var cls = opts.cls || '';
var size = opts.size || 16;
var activeClass = opts.active ? ' active' : '';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<button class="icon-btn' + activeClass + ' ' + cls + '" title="' + e(title) + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') + '>' + renderIcon(icon, size) + '</button>';
}
// -- Theme ------------------------------------------------
var Theme = {
_key: 'switchboard_theme',
init: function() {
var saved = localStorage.getItem(this._key) || 'system';
this.set(saved);
},
set: function(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get: function() { return localStorage.getItem(this._key) || 'system'; },
resolved: function() {
var mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle: function(containerId) {
var el = document.getElementById(containerId);
if (!el) return;
var current = this.get();
var options = [
{ id: 'light', label: 'Light', icon: '\u2600' },
{ id: 'dark', label: 'Dark', icon: '\u263E' },
{ id: 'system',label: 'System',icon: '\u229E' },
];
var self = this;
el.innerHTML = '<div class="theme-toggle">' + options.map(function(o) {
return '<button class="theme-toggle__btn' + (current === o.id ? ' active' : '') + '" data-theme="' + o.id + '" title="' + o.label + '">' + o.icon + '</button>';
}).join('') + '</div>';
el.querySelectorAll('.theme-toggle__btn').forEach(function(btn) {
btn.addEventListener('click', function() {
self.set(btn.dataset.theme);
self.renderToggle(containerId);
});
});
},
};
// -- Confirm Dialog (enhanced) ----------------------------
function showConfirmDialog(opts) {
opts = opts || {};
var title = opts.title || 'Confirm';
var message = opts.message || 'Are you sure?';
var confirmLabel = opts.confirmLabel || 'Confirm';
var cancelLabel = opts.cancelLabel || 'Cancel';
var variant = opts.variant || 'primary';
var onConfirm = opts.onConfirm;
var onCancel = opts.onCancel;
var e = typeof esc === 'function' ? esc : function(s) { return s; };
var overlay = document.getElementById('confirmOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'confirmOverlay';
overlay.className = 'confirm-overlay';
overlay.style.display = 'none';
document.body.appendChild(overlay);
}
overlay.innerHTML =
'<div class="confirm-dialog">' +
'<div class="confirm-dialog__title">' + e(title) + '</div>' +
'<div class="confirm-dialog__msg">' + e(message) + '</div>' +
'<div class="confirm-dialog__actions">' +
'<button class="btn btn-ghost" id="confirmCancelBtn">' + e(cancelLabel) + '</button>' +
'<button class="btn btn-' + variant + '" id="confirmOkBtn">' + e(confirmLabel) + '</button>' +
'</div>' +
'</div>';
overlay.style.display = 'flex';
function close() { overlay.style.display = 'none'; overlay.innerHTML = ''; }
document.getElementById('confirmCancelBtn').addEventListener('click', function() { close(); if (onCancel) onCancel(); });
document.getElementById('confirmOkBtn').addEventListener('click', function() { close(); if (onConfirm) onConfirm(); });
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { close(); if (onCancel) onCancel(); } });
}
// -- Avatar Upload Component ------------------------------
function mountAvatarUpload(containerEl, opts) {
opts = opts || {};
var current = opts.current;
var size = opts.size || 80;
var onUpload = opts.onUpload;
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
var e = typeof esc === 'function' ? esc : function(s) { return s; };
if (src) containerEl.innerHTML = '<img src="' + e(src) + '" alt="Avatar">';
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(current || null);
containerEl.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() {
var file = input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
render(ev.target.result);
if (onUpload) onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}

View File

@@ -1,6 +1,6 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Loaded in base.html before all components and surfaces.
// Follows the renderPersonaForm() pattern: (container, options) → control object.
// Designed for extension hooks: registries are open for .add(), primitives
// accept options for customization, and return handles for programmatic control.
@@ -8,6 +8,67 @@
'use strict';
// ══════════════════════════════════════════════════════════════════════════════
// §0 CORE UTILITIES
// ══════════════════════════════════════════════════════════════════════════════
/** HTML-escape a string for safe insertion into innerHTML and attributes. */
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML.replace(/"/g, '&quot;');
}
// ══════════════════════════════════════════════════════════════════════════════
// §0b COMPONENT LIFECYCLE
// ══════════════════════════════════════════════════════════════════════════════
/**
* Create a component registry (factory namespace with instance tracking).
*
* @param {string} name - Component name (for debug)
* @returns {{ primary, _instances, _register, _unregister, get }}
*/
function createComponentRegistry(name) {
return {
primary: null,
_instances: new Map(),
_register(id, instance) { this._instances.set(id, instance); },
_unregister(id) {
this._instances.delete(id);
if (this.primary && this.primary.id === id) this.primary = null;
},
get(id) { return this._instances.get(id) || null; },
};
}
/**
* Mix lifecycle methods into a component instance.
* Adds _listeners array, _on() for tracked event binding, and destroy()
* for automatic cleanup + registry removal.
* Components needing extra teardown define _cleanup() which is called first.
*
* @param {Object} instance - The instance object (must have .id)
* @param {Object} registry - The component registry (from createComponentRegistry)
* @returns {Object} The instance, mutated with lifecycle methods
*/
function componentMixin(instance, registry) {
instance._listeners = [];
instance._on = function(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
};
instance.destroy = function() {
if (typeof this._cleanup === 'function') this._cleanup();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
registry._unregister(this.id);
};
return instance;
}
// ══════════════════════════════════════════════════════════════════════════════
// §1 PROVIDER REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
@@ -933,3 +994,98 @@ function updateTabArrows(tabs) {
left.classList.toggle('visible', !atStart);
right.classList.toggle('visible', !atEnd);
}
// ══════════════════════════════════════════════════════════════════════════════
// §9 THEME
// ══════════════════════════════════════════════════════════════════════════════
const Theme = {
_key: 'switchboard_theme',
init() {
this.set(localStorage.getItem(this._key) || 'system');
},
set(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get() {
return localStorage.getItem(this._key) || 'system';
},
resolved() {
const mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle(containerId) {
const el = document.getElementById(containerId);
if (!el) return;
const current = this.get();
const options = [
{ id: 'light', label: 'Light', icon: '☀' },
{ id: 'dark', label: 'Dark', icon: '☾' },
{ id: 'system',label: 'System',icon: '⊞' },
];
el.innerHTML = '<div class="toggle-group">' + options.map(o =>
`<button class="toggle-btn${current === o.id ? ' active' : ''}" data-theme="${o.id}" title="${o.label}">${o.icon}</button>`
).join('') + '</div>';
el.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.set(btn.dataset.theme);
this.renderToggle(containerId);
});
});
},
};
// ══════════════════════════════════════════════════════════════════════════════
// §10 AVATAR UPLOAD
// ══════════════════════════════════════════════════════════════════════════════
/**
* Mount an avatar upload component into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.current] - Current avatar URL
* @param {number} [opts.size=80] - Avatar size in px
* @param {Function} [opts.onUpload] - Called with (file, dataURL)
* @returns {{ setImage }}
*/
function mountAvatarUpload(containerEl, opts = {}) {
const size = opts.size || 80;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
if (src) containerEl.innerHTML = `<img src="${esc(src)}" alt="Avatar">`;
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(opts.current || null);
containerEl.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
render(ev.target.result);
if (opts.onUpload) opts.onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}

View File

@@ -107,7 +107,7 @@ Object.assign(UI, {
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
const activeThemeBtn = document.querySelector('#themeToggle .theme-btn.active');
const activeThemeBtn = document.querySelector('#themeToggle .toggle-btn.active');
if (activeThemeBtn && typeof Theme !== 'undefined') {
Theme.set(activeThemeBtn.dataset.theme);
}
@@ -156,17 +156,17 @@ Object.assign(UI, {
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button and wire click
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
btn.onclick = () => {
document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
};
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
},
@@ -192,11 +192,11 @@ Object.assign(UI, {
});
// Theme toggle buttons
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme);
document.querySelectorAll('#themeToggle .theme-btn').forEach(b =>
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
@@ -206,10 +206,10 @@ Object.assign(UI, {
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .theme-btn').forEach(b =>
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
@@ -395,7 +395,7 @@ Object.assign(UI, {
</select>
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
<button class="icon-btn icon-btn-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>

View File

@@ -12,12 +12,11 @@
// menu.destroy();
const UserMenu = {
primary: null,
_instances: new Map(),
...createComponentRegistry('UserMenu'),
create(opts) {
const pfx = opts.id || '';
const instance = {
const instance = componentMixin({
id: pfx,
btnEl: document.getElementById(pfx + 'userMenuBtn'),
avatarEl: document.getElementById(pfx + 'userAvatar'),
@@ -30,7 +29,6 @@ const UserMenu = {
debugEl: document.getElementById(pfx + 'menuDebug'),
signoutEl: document.getElementById(pfx + 'menuSignout'),
_open: false,
_listeners: [],
// ── User display ────────────────────
@@ -127,28 +125,9 @@ const UserMenu = {
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
},
}, UserMenu);
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
UserMenu._instances.delete(this.id);
if (UserMenu.primary === this) UserMenu.primary = null;
},
// ── Internal ────────────────────────
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
UserMenu._instances.set(pfx, instance);
UserMenu._register(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};