rebrand + purge orphaned frontend: Chat Switchboard → Switchboard Core
Some checks failed
CI/CD / detect-changes (push) Successful in 23s
CI/CD / test-sqlite (push) Failing after 20s
CI/CD / test-frontend (push) Failing after 21s
CI/CD / test-go-pg (push) Failing after 38s
CI/CD / build-and-deploy (push) Has been skipped

Branding: bulk rename across 50+ files (Go, JS, CSS, HTML, YAML, JSON,
shell scripts). Fix Helm chart description and git repo URLs.
Fix test helper taglines.

Admin surface: strip 14 gutted tabs (providers, models, personas,
roles, knowledge, memory, tasks, health, routing, capabilities,
channels, dashboard, usage, stats). Collapse to 4 categories:
People, Workflows, System, Monitoring.

Settings surface: strip 11 gutted tabs (models, personas, providers,
roles, knowledge, memory, usage, workflows, tasks, data, gitkeys).
Keep: general, appearance, profile, teams, connections, notifications.

Team-admin surface: strip 5 gutted tabs (personas, providers,
knowledge, tasks, usage). Keep: members, groups, connections,
workflows, settings, activity.

Components: delete chat-pane/ (13 files, 1938 lines) and
notes-pane/ (10 files, 2381 lines).

main.go: remove stale Health.Prune maintenance goroutine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:28:09 +00:00
parent 45291860c5
commit c470037fb5
107 changed files with 83 additions and 7744 deletions

View File

@@ -1,59 +0,0 @@
/**
* Settings > Data & Privacy — export and account deletion
*/
const { html } = window;
const { useState, useCallback } = hooks;
export default function DataSection() {
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const exportData = useCallback(async () => {
setExporting(true);
try {
await sw.api.dataPortability.exportMe();
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setExporting(false); }
}, []);
const deleteAccount = useCallback(async () => {
const ok = await sw.confirm(
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
true,
);
if (!ok) return;
setDeleting(true);
try {
await sw.api.dataPortability.deleteAccount({ confirm: true });
sw.toast('Account deletion initiated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setDeleting(false); }
}, []);
return html`
<div>
<div class="settings-section">
<h3>Export Your Data</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Download a copy of all your data including conversations, settings, and memories.
The export will be prepared and a download link will be provided.
</p>
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
${exporting ? 'Preparing\u2026' : 'Export My Data'}
</button>
</div>
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
<h3 style="color:var(--danger);">Danger Zone</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Permanently delete your account and all associated data. This action cannot be undone.
We recommend exporting your data before proceeding.
</p>
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
</button>
</div>
</div>
`;
}

View File

@@ -1,97 +0,0 @@
/**
* Settings > Git Keys — SSH credential management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function GitKeysSection() {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.git.credentials.list();
setKeys(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function addKey(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
const key = form.key.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.git.credentials.create({ name, public_key: key || undefined });
sw.toast('Credential added', 'success');
form.reset();
setShowAdd(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKey(id) {
const ok = await sw.confirm('Delete this SSH credential?', true);
if (!ok) return;
try {
await sw.api.git.credentials.del(id);
sw.toast('Credential deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
${showAdd ? 'Cancel' : '+ Add Key'}
</button>
</div>
${showAdd && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. deploy-key-prod" required />
</div>
<div class="form-group">
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
</div>
<button type="submit" class="btn-small btn-primary">Add Credential</button>
</form>
`}
<div class="admin-list">
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
${keys.map(k => html`
<div class="admin-surface-row" key=${k.id}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
${k.fingerprint && html`
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
${k.fingerprint.substring(0, 24)}\u2026
</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -26,54 +26,27 @@ const sectionModules = {
appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'),
teams: () => import('./teams.js'),
models: () => import('./models.js'),
providers: () => import('./providers.js'),
connections: () => import('./connections.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
usage: () => import('./usage.js'),
workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
gitkeys: () => import('./gitkeys.js'),
data: () => import('./data.js'),
knowledge: () => import('./knowledge.js'),
memory: () => import('./memory.js'),
notifications: () => import('./notifications.js'),
};
// ── Nav structure ───────────────────────────
const NAV_ITEMS = [
{ key: 'general', label: 'General' },
{ key: 'appearance', label: 'Appearance' },
{ key: 'models', label: 'Models' },
{ key: 'personas', label: 'Personas', gate: 'personas' },
{ key: 'profile', label: 'Profile' },
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Assignments' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'general', label: 'General' },
{ key: 'appearance', label: 'Appearance' },
{ key: 'profile', label: 'Profile' },
{ key: 'teams', label: 'Teams' },
{ key: 'connections', label: 'Connections' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'knowledge', label: 'Knowledge Bases' },
{ key: 'memory', label: 'Memory' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'data', label: 'Data & Privacy' },
];
const BYOK_ITEMS = [
{ key: 'providers', label: 'My Providers' },
{ key: 'roles', label: 'Model Roles' },
{ key: 'usage', label: 'My Usage' },
];
const BYOK_ITEMS = [];
// ── Section title map ───────────────────────
const SECTION_TITLES = {
general: 'General', appearance: 'Appearance', models: 'Models',
personas: 'Personas', profile: 'Profile', teams: 'Teams',
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
connections: 'Connections',
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
notifications: 'Notifications',
general: 'General', appearance: 'Appearance',
profile: 'Profile', teams: 'Teams',
connections: 'Connections', notifications: 'Notifications',
};
// ── v0.38.3: Extension config sections ──────

View File

@@ -1,186 +0,0 @@
/**
* Settings > Knowledge — personal knowledge base management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function formatBytes(b) {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
export default function KnowledgeSection() {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [docs, setDocs] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.knowledge.list();
setKbs(resp.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(kb) {
setDetail(kb);
try {
const d = await sw.api.knowledge.documents(kb.id);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
async function createKb(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.knowledge.create({
name,
description: form.description.value.trim(),
});
sw.toast('Knowledge base created', 'success');
form.reset();
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKb(id) {
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
if (!ok) return;
try {
await sw.api.knowledge.del(id);
sw.toast('Knowledge base deleted', 'success');
setDetail(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function uploadDoc() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.md,.csv,.html,.pdf';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
await sw.api.knowledge.upload(detail.id, file);
sw.toast('Document uploaded', 'success');
const d = await sw.api.knowledge.documents(detail.id);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
};
input.click();
}
async function deleteDoc(docId) {
const ok = await sw.confirm('Delete this document?', true);
if (!ok) return;
try {
await sw.api.knowledge.delDoc(detail.id, docId);
sw.toast('Document deleted', 'success');
setDocs(prev => prev.filter(d => d.id !== docId));
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
const canCreate = sw.can('kb.create');
const canWrite = sw.can('kb.write');
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
// Detail view
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
${statusBadge(detail.status || 'active')}
<div style="flex:1"></div>
${canWrite && html`<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>`}
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>`}
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
`}
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
</p>
<div class="admin-list">
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
${docs.map(d => html`
<div class="admin-surface-row" key=${d.id}>
<div style="flex:1;min-width:0;">
<strong>${d.filename}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
${' '}${statusBadge(d.status || 'pending')}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>`}
</div>
`)}
</div>
</div>
`;
// List view
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
${canCreate && html`
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
${showCreate ? 'Cancel' : '+ Create'}
</button>`}
</div>
${showCreate && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. Product Documentation" required />
</div>
<div class="form-group">
<label>Description</label>
<input name="description" placeholder="Optional description" />
</div>
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
</form>
`}
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
${statusBadge(k.status || 'active')}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -1,117 +0,0 @@
/**
* Settings > Memory — personal memory management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function truncate(s, len) {
if (!s) return '';
return s.length > len ? s.substring(0, len) + '\u2026' : s;
}
export default function MemorySection() {
const [memories, setMemories] = useState([]);
const [loading, setLoading] = useState(true);
const [count, setCount] = useState(0);
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState('');
const load = useCallback(async () => {
try {
const [data, c] = await Promise.all([
sw.api.memory.list(),
sw.api.memory.count().catch(() => null),
]);
setMemories(data || []);
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
function startEdit(m) {
setEditingId(m.id);
setEditValue(m.value || m.content || '');
}
function cancelEdit() {
setEditingId(null);
setEditValue('');
}
async function saveEdit(id) {
try {
await sw.api.memory.update(id, { value: editValue, content: editValue });
sw.toast('Memory updated', 'success');
setEditingId(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteMemory(id) {
const ok = await sw.confirm('Delete this memory?', true);
if (!ok) return;
try {
await sw.api.memory.del(id);
sw.toast('Memory deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
if (!status) return null;
const cls = status === 'approved' ? 'badge-active'
: status === 'rejected' ? 'badge-inactive'
: 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
<div style="flex:1"></div>
</div>
<div class="admin-list">
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
${memories.map(m => html`
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
${editingId === m.id ? html`
<div>
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
value=${editValue}
onInput=${e => setEditValue(e.target.value)} />
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
</div>
</div>
` : html`
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
</div>
${statusBadge(m.status)}
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
</div>
<div style="display:flex;gap:6px;margin-top:6px;">
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
</div>
`}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -1,110 +0,0 @@
/**
* ModelsSection — user model list with visibility toggle
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function ModelsSection() {
const [models, setModels] = useState(null);
const [prefs, setPrefs] = useState({});
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [provFilter, setProvFilter] = useState('');
useEffect(() => {
(async () => {
try {
const [m, p] = await Promise.all([
sw.api.models.enabled(),
sw.api.models.preferences(),
]);
setModels(m.data || []);
// Build prefs map: model_id → { hidden }
const map = {};
(p || []).forEach(x => { map[x.model_id] = x; });
setPrefs(map);
} catch (e) {
console.warn('[settings/models]', e.message);
setModels([]);
}
})();
}, []);
const toggleHidden = useCallback(async (model) => {
const current = prefs[model.id]?.hidden || false;
try {
await sw.api.models.setPref(model.id, model.provider_config_id, !current);
setPrefs(p => ({ ...p, [model.id]: { ...p[model.id], hidden: !current } }));
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [prefs]);
if (models === null) {
return html`<div class="settings-placeholder">Loading models\u2026</div>`;
}
// Derive filter options
const types = [...new Set(models.map(m => m.model_type).filter(Boolean))];
const providers = [...new Set(models.map(m => m.provider_name || m.provider).filter(Boolean))];
const filtered = models.filter(m => {
const name = (m.display_name || m.model_id || '').toLowerCase();
if (search && !name.includes(search.toLowerCase())) return false;
if (typeFilter && m.model_type !== typeFilter) return false;
if (provFilter && (m.provider_name || m.provider) !== provFilter) return false;
return true;
});
return html`
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
<input type="text" placeholder="Search models\u2026"
style="flex:1;min-width:140px;"
value=${search} onInput=${e => setSearch(e.target.value)} />
${types.length > 1 && html`
<select value=${typeFilter} onChange=${e => setTypeFilter(e.target.value)}>
<option value="">All types</option>
${types.map(t => html`<option value=${t}>${t}</option>`)}
</select>
`}
${providers.length > 1 && html`
<select value=${provFilter} onChange=${e => setProvFilter(e.target.value)}>
<option value="">All providers</option>
${providers.map(p => html`<option value=${p}>${p}</option>`)}
</select>
`}
</div>
<table class="admin-table">
<thead>
<tr><th>Model</th><th>Type</th><th>Provider</th><th style="width:60px;">Visible</th></tr>
</thead>
<tbody>
${filtered.map(m => {
const hidden = prefs[m.id]?.hidden || false;
return html`
<tr style=${hidden ? 'opacity:0.5' : ''}>
<td style="font-weight:500;font-size:13px;">
${esc(m.display_name || m.model_id)}
</td>
<td style="font-size:12px;color:var(--text-2);">
${esc(m.model_type)}
</td>
<td style="font-size:12px;color:var(--text-2);">
${esc(m.provider_name || m.provider)}
</td>
<td>
<input type="checkbox" checked=${!hidden}
onChange=${() => toggleHidden(m)} />
</td>
</tr>
`;
})}
</tbody>
</table>
<div style="font-size:12px;color:var(--text-3);margin-top:8px;">
${filtered.length} model${filtered.length !== 1 ? 's' : ''}
</div>
`;
}

View File

@@ -1,159 +0,0 @@
/**
* PersonasSection — user persona list + create/edit
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function PersonasSection() {
const [personas, setPersonas] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', description: '', system_prompt: '' });
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.personas.list();
setPersonas(resp || []);
} catch (e) {
setPersonas([]);
}
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = useCallback(() => {
setEditId(null);
setForm({ name: '', description: '', system_prompt: '' });
setShowForm(true);
}, []);
const openEdit = useCallback((p) => {
setEditId(p.id);
setForm({ name: p.name || '', description: p.description || '', system_prompt: p.system_prompt || '' });
setShowForm(true);
}, []);
const cancel = useCallback(() => {
setShowForm(false);
setEditId(null);
}, []);
const save = useCallback(async () => {
if (!form.name.trim()) {
sw.emit('toast', { message: 'Name is required', variant: 'error' });
return;
}
setSaving(true);
try {
if (editId) {
await sw.api.personas.update(editId, form);
sw.emit('toast', { message: 'Persona updated', variant: 'success' });
} else {
await sw.api.personas.create(form);
sw.emit('toast', { message: 'Persona created', variant: 'success' });
}
setShowForm(false);
setEditId(null);
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [form, editId, load]);
const del = useCallback(async (p) => {
const ok = await sw.confirm(`Delete persona "${p.name}"?`);
if (!ok) return;
try {
await sw.api.personas.del(p.id);
sw.emit('toast', { message: 'Persona deleted', variant: 'success' });
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [load]);
const setField = useCallback((key, val) => {
setForm(f => ({ ...f, [key]: val }));
}, []);
if (personas === null) {
return html`<div class="settings-placeholder">Loading personas\u2026</div>`;
}
const canCreate = sw.can('persona.create');
const canManage = sw.can('persona.manage');
return html`
${canCreate && html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}>+ New Persona</button>
</div>`}
${showForm && html`
<div class="settings-section" style="margin-bottom:16px;">
<h3>${editId ? 'Edit Persona' : 'New Persona'}</h3>
<div class="form-group">
<label>Name</label>
<input type="text" value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
<div class="form-group">
<label>Description</label>
<input type="text" value=${form.description}
onInput=${e => setField('description', e.target.value)} />
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea rows="4" value=${form.system_prompt}
onInput=${e => setField('system_prompt', e.target.value)}
style="max-width:100%;" />
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Create'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!personas.length && !showForm && html`
<div class="empty-hint">No personas configured.</div>
`}
${personas.length > 0 && html`
<div>
${personas.map(p => html`
<div class="settings-section" style="margin-bottom:12px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<strong style="font-size:14px;">${esc(p.name)}</strong>
${p.description && html`
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">
${esc(p.description)}
</div>
`}
</div>
${canManage && html`
<div style="display:flex;gap:4px;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(p)}>
\u{1F5D1}
</button>
</div>`}
</div>
</div>
`)}
</div>
`}
`;
}

View File

@@ -1,213 +0,0 @@
/**
* ProvidersSection — BYOK provider CRUD (user-scoped)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function ProvidersSection() {
const [providers, setProviders] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.providers.list();
setProviders(resp || []);
} catch (e) {
setProviders([]);
}
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = useCallback(() => {
setEditId(null);
setForm({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
setShowForm(true);
}, []);
const openEdit = useCallback(async (prov) => {
try {
const cfg = await sw.api.providers.get(prov.id);
setEditId(cfg.id);
setForm({
name: cfg.name || '',
provider: cfg.provider || 'openai',
endpoint: cfg.endpoint || '',
api_key: '',
model_default: cfg.model_default || '',
});
setShowForm(true);
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const cancel = useCallback(() => {
setShowForm(false);
setEditId(null);
}, []);
const save = useCallback(async () => {
if (!form.name || !form.endpoint) {
sw.emit('toast', { message: 'Name and endpoint are required', variant: 'error' });
return;
}
if (!editId && !form.api_key) {
sw.emit('toast', { message: 'API key is required', variant: 'error' });
return;
}
setSaving(true);
try {
if (editId) {
const patch = { name: form.name, provider: form.provider, endpoint: form.endpoint, model_default: form.model_default };
if (form.api_key) patch.api_key = form.api_key;
await sw.api.providers.update(editId, patch);
sw.emit('toast', { message: 'Provider updated', variant: 'success' });
} else {
const body = { name: form.name, provider: form.provider, endpoint: form.endpoint, api_key: form.api_key, model_default: form.model_default };
const result = await sw.api.providers.create(body);
const count = result?.models_fetched || 0;
sw.emit('toast', {
message: result?.warning || `Provider added \u2014 ${count} model${count !== 1 ? 's' : ''} synced`,
variant: result?.warning ? 'warning' : 'success',
});
}
setShowForm(false);
setEditId(null);
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [form, editId, load]);
const del = useCallback(async (prov) => {
const ok = await sw.confirm(`Remove provider "${prov.name}"?`);
if (!ok) return;
try {
await sw.api.providers.del(prov.id);
sw.emit('toast', { message: 'Provider removed', variant: 'success' });
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [load]);
const sync = useCallback(async (prov) => {
sw.emit('toast', { message: `Fetching models for ${prov.name}\u2026`, variant: 'info' });
try {
const result = await sw.api.providers.fetchModels(prov.id);
const total = result?.total || 0;
sw.emit('toast', { message: `${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const testConnection = useCallback(async (prov) => {
sw.emit('toast', { message: `Testing ${prov.name}\u2026`, variant: 'info' });
try {
const result = await sw.api.providers.fetchModels(prov.id);
const total = result?.total || 0;
sw.emit('toast', { message: `${prov.name}: connection OK (${total} model${total !== 1 ? 's' : ''})`, variant: 'success' });
} catch (e) {
sw.emit('toast', { message: `${prov.name}: connection failed \u2014 ${e.message}`, variant: 'error' });
}
}, []);
const setField = useCallback((key, val) => {
setForm(f => ({ ...f, [key]: val }));
}, []);
if (providers === null) {
return html`<div class="settings-placeholder">Loading providers\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Provider</button>
</div>
${showForm && html`
<div class="settings-section" style="margin-bottom:16px;">
<h3>${editId ? 'Edit Provider' : 'Add Provider'}</h3>
<div class="form-group">
<label>Name</label>
<input type="text" value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
<div class="form-group">
<label>Provider Type</label>
<select value=${form.provider}
onChange=${e => setField('provider', e.target.value)}>
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
<option value="openrouter">OpenRouter</option>
<option value="venice">Venice AI</option>
</select>
</div>
<div class="form-group">
<label>Endpoint URL</label>
<input type="text" placeholder="https://api.openai.com/v1"
value=${form.endpoint}
onInput=${e => setField('endpoint', e.target.value)} />
</div>
<div class="form-group">
<label>API Key${editId ? ' (leave blank to keep current)' : ''}</label>
<input type="password" placeholder="sk-..."
value=${form.api_key}
onInput=${e => setField('api_key', e.target.value)} />
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!providers.length && !showForm && html`
<div class="empty-hint">No providers configured.</div>
`}
${providers.length > 0 && html`
<table class="admin-table">
<thead><tr><th>Provider</th><th>Type</th><th>Endpoint</th><th></th></tr></thead>
<tbody>
${providers.map(p => html`
<tr>
<td style="font-weight:500;font-size:13px;">${esc(p.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(p.provider)}</td>
<td style="font-size:12px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${esc(p.endpoint)}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Test connection" onClick=${() => testConnection(p)}>
\u{26A1}
</button>
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
\u{1F504}
</button>
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(p)}>
\u{1F5D1}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
`;
}

View File

@@ -1,127 +0,0 @@
/**
* RolesSection — user model role overrides (requires BYOK providers)
*
* Each "role" (primary, fallback) can be overridden to use a personal
* provider + model instead of the org default.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
const ROLE_NAMES = ['primary', 'fallback'];
export function RolesSection() {
const [providers, setProviders] = useState([]);
const [models, setModels] = useState([]);
const [roles, setRoles] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const [provResp, modResp, settings] = await Promise.all([
sw.api.providers.list(),
sw.api.models.enabled(),
sw.api.profile.settings(),
]);
const provs = (provResp || [])
.filter(c => c.scope === 'personal');
setProviders(provs);
setModels(modResp.data || []);
// Extract role overrides from settings
const overrides = settings?.model_roles || {};
setRoles(overrides);
} catch (e) {
console.warn('[settings/roles]', e.message);
} finally {
setLoading(false);
}
})();
}, []);
const setRoleProvider = useCallback((role, provId) => {
setRoles(r => ({
...r,
[role]: { ...(r[role] || {}), provider_config_id: provId, model_id: '' },
}));
}, []);
const setRoleModel = useCallback((role, modelId) => {
setRoles(r => ({
...r,
[role]: { ...(r[role] || {}), model_id: modelId },
}));
}, []);
const save = useCallback(async () => {
setSaving(true);
try {
await sw.api.profile.updateSettings({ model_roles: roles });
sw.emit('toast', { message: 'Roles saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [roles]);
if (loading) {
return html`<div class="settings-placeholder">Loading\u2026</div>`;
}
if (!providers.length) {
return html`
<div class="settings-section">
<p style="color:var(--text-2);font-size:13px;">
Add a personal provider first to configure model role overrides.
</p>
</div>
`;
}
return html`
${ROLE_NAMES.map(role => {
const r = roles[role] || {};
const provId = r.provider_config_id || '';
const modelId = r.model_id || '';
const filteredModels = provId
? models.filter(m => m.provider_config_id === provId)
: [];
return html`
<div class="settings-section" style="margin-bottom:16px;">
<h3 style="text-transform:capitalize;">${role}</h3>
<div style="display:flex;gap:12px;">
<div class="form-group" style="flex:1;">
<label>Provider</label>
<select value=${provId}
onChange=${e => setRoleProvider(role, e.target.value)}>
<option value="">\u2014 use org default \u2014</option>
${providers.map(p => html`
<option value=${p.id}>${esc(p.name)}</option>
`)}
</select>
</div>
<div class="form-group" style="flex:1;">
<label>Model</label>
<select value=${modelId} disabled=${!provId}
onChange=${e => setRoleModel(role, e.target.value)}>
<option value="">\u2014 select model \u2014</option>
${filteredModels.map(m => html`
<option value=${m.model_id || m.id}>
${esc(m.display_name || m.model_id || m.id)}
</option>
`)}
</select>
</div>
</div>
</div>
`;
})}
<button class="btn-md btn-primary" disabled=${saving} onClick=${save}>
${saving ? 'Saving\u2026' : 'Save Roles'}
</button>
`;
}

View File

@@ -1,71 +0,0 @@
/**
* Settings > Tasks — user's tasks with run/stop controls
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function TasksSection() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.tasks.list();
setTasks(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function runTask(id) {
try {
await sw.api.tasks.start(id);
sw.toast('Task started', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function stopTask(id) {
try {
await sw.api.tasks.stop(id);
sw.toast('Task stopped', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
${tasks.map(t => html`
<div class="admin-surface-row" key=${t.id}>
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
${t.schedule && html`
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
${t.is_active ? 'running' : 'stopped'}
</span>
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -1,65 +0,0 @@
/**
* UsageSection — personal usage statistics
*/
const { html } = window;
const { useState, useEffect } = hooks;
function esc(s) { return s == null ? '' : String(s); }
function fmt(n) { return n == null ? '0' : n.toLocaleString(); }
export function UsageSection() {
const [usage, setUsage] = useState(null);
useEffect(() => {
// Use the generic API escape hatch since there's no dedicated namespace
sw.api.get('/api/v1/profile/usage').then(data => {
setUsage(data);
}).catch(() => setUsage({}));
}, []);
if (usage === null) {
return html`<div class="settings-placeholder">Loading usage\u2026</div>`;
}
const totals = usage.totals || usage;
const byModel = usage.by_model || [];
return html`
<div class="settings-section">
<h3>Totals</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
${[
['Requests', totals.request_count],
['Input Tokens', totals.input_tokens],
['Output Tokens', totals.output_tokens],
['Total Tokens', totals.total_tokens],
].map(([label, val]) => html`
<div style="background:var(--bg-raised);border-radius:8px;padding:14px;">
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px;">${label}</div>
<div style="font-size:18px;font-weight:600;">${fmt(val)}</div>
</div>
`)}
</div>
</div>
${byModel.length > 0 && html`
<div class="settings-section" style="margin-top:16px;">
<h3>By Model</h3>
<table class="admin-table">
<thead>
<tr><th>Model</th><th>Requests</th><th>Tokens</th></tr>
</thead>
<tbody>
${byModel.map(m => html`
<tr>
<td style="font-size:13px;">${esc(m.model_id || m.model)}</td>
<td style="font-size:12px;">${fmt(m.request_count)}</td>
<td style="font-size:12px;">${fmt(m.total_tokens)}</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`;
}

View File

@@ -1,138 +0,0 @@
/**
* Settings > Workflows — v0.37.15 rewrite
*
* Replaces read-only workflow list with MyAssignments (E4).
* Shows the user's personal assignment queue across all teams.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function _timeAgo(ts) {
if (!ts) return '';
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
export default function WorkflowsSection() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
const userId = sw.auth?.user?.id;
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
const available = assignments.filter(a => a.status === 'unassigned');
async function claim(id) {
try {
await sw.api.workflowAssignments.claim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim(id) {
try {
await sw.api.workflowAssignments.unclaim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete(id) {
try {
await sw.api.workflowAssignments.complete(id);
sw.toast('Assignment completed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${mine.length > 0 && html`
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
<div class="admin-list">
${mine.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${available.length > 0 && html`
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
<div class="admin-list">
${available.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${mine.length === 0 && available.length === 0 && html`
<div class="empty-hint">No assignments</div>
`}
</div>
`;
}
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const userId = sw.auth?.user?.id;
const isMine = a.assigned_to === userId;
async function claim() {
try {
await sw.api.workflowAssignments.claim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim() {
try {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete() {
try {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<div style="display:flex;gap:4px;">
${a.status === 'unassigned' && html`
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
`}
${isMine && html`
<button class="btn-small" onClick=${unclaim}>Release</button>
<button class="btn-small btn-primary"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
`}
</div>
</div>
`;
}