Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -18,8 +18,8 @@ export default function CapabilitiesSection() {
sw.api.admin.models.overrides(),
sw.api.admin.models.list().catch(() => []),
]);
setOverrides(Array.isArray(o) ? o : o.data || []);
setModels(Array.isArray(m) ? m : m.models || m.data || []);
setOverrides(o.data || []);
setModels(m || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -1,5 +1,5 @@
/**
* Admin > System > Channels (Archived)
* Admin > System > Channels (Archived + Retention)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
@@ -7,21 +7,34 @@ const { useState, useEffect, useCallback } = hooks;
export default function ChannelsSection() {
const [channels, setChannels] = useState([]);
const [total, setTotal] = useState(0);
const [retentionMode, setRetentionMode] = useState('');
const [retentionTTL, setRetentionTTL] = useState(0);
const [ttlDraft, setTTLDraft] = useState('');
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.admin.channels.archived();
setChannels(data.channels || data.data || []);
setChannels(data.data || []);
setTotal(data.total || 0);
setRetentionMode(data.retention_mode || '');
const ttl = data.retention_ttl_days || 0;
setRetentionTTL(ttl);
setTTLDraft(String(ttl));
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function saveTTL() {
const val = parseInt(ttlDraft, 10);
if (isNaN(val) || val < 0) { sw.toast('TTL must be 0 or more', 'error'); return; }
try {
await sw.api.admin.settings.update('retention_ttl_days', { value: { value: val } });
setRetentionTTL(val);
sw.toast('Retention TTL saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function purge(id, title) {
const ok = await sw.confirm(`Permanently delete "${title || id}"? This cannot be undone.`, true);
if (!ok) return;
@@ -38,9 +51,27 @@ export default function ChannelsSection() {
<div>
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Archived</div></div>
<div class="stat-card"><div class="stat-value">${retentionMode || '\u2014'}</div><div class="stat-label">Retention Mode</div></div>
<div class="stat-card"><div class="stat-value">${retentionTTL || 'Off'}</div><div class="stat-label">Retention TTL (days)</div></div>
</div>
<div class="admin-section" style="margin-bottom:20px;">
<h4 style="margin:0 0 8px;">Retention Policy</h4>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
Channels are archived on delete and purged after this many days.
Only Personal (BYOK) provider channels are exempt. Set to 0 to disable retention.
</p>
<div style="display:flex;gap:8px;align-items:center;">
<input type="number" min="0" value=${ttlDraft}
onInput=${e => setTTLDraft(e.target.value)}
style="width:80px;padding:6px 8px;background:var(--bg-2);color:var(--text);border:1px solid var(--border);border-radius:4px;"
placeholder="0" />
<span class="text-muted" style="font-size:12px;">days</span>
<button class="btn-small" onClick=${saveTTL}
disabled=${String(retentionTTL) === ttlDraft}>Save</button>
</div>
</div>
<h4 style="margin:0 0 8px;">Archived Channels</h4>
<div class="admin-list">
${channels.length === 0 && html`<div class="empty-hint">No archived channels</div>`}
${channels.map(c => html`

View File

@@ -19,7 +19,7 @@ export default function GroupsSection() {
const loadGroups = useCallback(async () => {
try {
const data = await sw.api.admin.groups.list();
setGroups(Array.isArray(data) ? data : data.data || []);
setGroups(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -41,10 +41,10 @@ export default function GroupsSection() {
sw.api.admin.users.list(),
sw.api.admin.models.list().catch(() => []),
]);
setMembers(Array.isArray(m) ? m : m.data || []);
setMembers(m || []);
setAllPerms(p.permissions || []);
setAllUsers(Array.isArray(u) ? u : u.users || []);
setAllModels(Array.isArray(models) ? models : models.models || models.data || []);
setAllUsers(u || []);
setAllModels(models || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -102,7 +102,7 @@ export default function GroupsSection() {
sw.toast('Member added', 'success');
setShowAddMember(false);
const m = await sw.api.admin.groups.members(detail.id);
setMembers(Array.isArray(m) ? m : m.data || []);
setMembers(m || []);
} catch (e) { sw.toast(e.message, 'error'); }
}

View File

@@ -12,7 +12,7 @@ export default function HealthSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.providers.health();
setProviders(Array.isArray(data) ? data : data.data || []);
setProviders(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -46,30 +46,32 @@ const CATEGORY_META = {
};
// ── Lazy section imports ────────────────────
// Version query busts browser module cache on upgrades.
const _v = window.__VERSION__ ? `?v=${window.__VERSION__}` : '';
const sectionModules = {
users: () => import('./users.js'),
teams: () => import('./teams.js'),
groups: () => import('./groups.js'),
providers: () => import('./providers.js'),
models: () => import('./models.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
knowledgeBases: () => import('./knowledge.js'),
memory: () => import('./memory.js'),
workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
health: () => import('./health.js'),
routing: () => import('./routing.js'),
capabilities: () => import('./capabilities.js'),
settings: () => import('./settings.js'),
storage: () => import('./storage.js'),
packages: () => import('./packages.js'),
channels: () => import('./channels.js'),
broadcast: () => import('./broadcast.js'),
dashboard: () => import('./dashboard.js'),
usage: () => import('./usage.js'),
audit: () => import('./audit.js'),
stats: () => import('./stats.js'),
users: () => import(`./users.js${_v}`),
teams: () => import(`./teams.js${_v}`),
groups: () => import(`./groups.js${_v}`),
providers: () => import(`./providers.js${_v}`),
models: () => import(`./models.js${_v}`),
personas: () => import(`./personas.js${_v}`),
roles: () => import(`./roles.js${_v}`),
knowledgeBases: () => import(`./knowledge.js${_v}`),
memory: () => import(`./memory.js${_v}`),
workflows: () => import(`./workflows.js${_v}`),
tasks: () => import(`./tasks.js${_v}`),
health: () => import(`./health.js${_v}`),
routing: () => import(`./routing.js${_v}`),
capabilities: () => import(`./capabilities.js${_v}`),
settings: () => import(`./settings.js${_v}`),
storage: () => import(`./storage.js${_v}`),
packages: () => import(`./packages.js${_v}`),
channels: () => import(`./channels.js${_v}`),
broadcast: () => import(`./broadcast.js${_v}`),
dashboard: () => import(`./dashboard.js${_v}`),
usage: () => import(`./usage.js${_v}`),
audit: () => import(`./audit.js${_v}`),
stats: () => import(`./stats.js${_v}`),
};
// ── Derive category from section ────────────

View File

@@ -12,8 +12,8 @@ export default function KnowledgeSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.knowledge.list();
setKbs(Array.isArray(data) ? data : data.data || []);
const resp = await sw.api.knowledge.list();
setKbs(resp.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -24,7 +24,7 @@ export default function KnowledgeSection() {
setDetail(kb);
try {
const d = await sw.api.knowledge.documents(kb.id);
setDocs(Array.isArray(d) ? d : d.data || []);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -39,7 +39,7 @@ export default function KnowledgeSection() {
await sw.api.knowledge.upload(detail.id, file);
sw.toast('Document uploaded', 'success');
const d = await sw.api.knowledge.documents(detail.id);
setDocs(Array.isArray(d) ? d : d.data || []);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
};
input.click();

View File

@@ -11,7 +11,7 @@ export default function MemorySection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.memories.pending();
setPending(Array.isArray(data) ? data : data.data || []);
setPending(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -13,7 +13,7 @@ export default function ModelsSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.models.list();
setModels(Array.isArray(data) ? data : data.models || data.data || []);
setModels(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -1,69 +1,274 @@
/**
* Admin > System > Packages (Extensions)
* Admin > System > Packages
*
* Full package lifecycle: list, install (upload + registry), enable/disable,
* settings, export, delete. Uses /api/v1/admin/packages endpoints.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function PackagesSection() {
const [extensions, setExtensions] = useState([]);
const [loading, setLoading] = useState(true);
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow'];
const CORE_IDS = new Set(['chat', 'admin']);
function typeBadge(type) {
const cls = type === 'surface' ? 'badge-active'
: type === 'extension' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${type || '?'}</span>`;
}
function tierBadge(tier) {
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
}
function sourceBadge(source) {
if (!source || source === 'extension') return null;
const cls = source === 'core' ? 'badge-active' : source === 'registry' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${source}</span>`;
}
export default function PackagesSection() {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState('all');
const [expandedId, setExpandedId] = useState(null);
const [pkgSettings, setPkgSettings] = useState(null);
const [settingsDraft, setSettingsDraft] = useState({});
const [registryOpen, setRegistryOpen] = useState(false);
const [registryPkgs, setRegistryPkgs] = useState([]);
const [registryLoading, setRegistryLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const BASE = window.__BASE__ || '';
// ── Load packages ───────────────────────────
const load = useCallback(async () => {
try {
const data = await sw.api.admin.extensions.list();
setExtensions(Array.isArray(data) ? data : data.data || []);
const opts = typeFilter !== 'all' ? { type: typeFilter } : undefined;
const data = await sw.api.admin.packages.list(opts);
setPackages(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
}, [typeFilter]);
useEffect(() => { load(); }, []);
useEffect(() => { setLoading(true); load(); }, [typeFilter]);
async function toggleEnabled(ext) {
// ── Actions ─────────────────────────────────
async function toggleEnabled(pkg) {
if (CORE_IDS.has(pkg.id)) return;
try {
await sw.api.admin.extensions.update(ext.id, { is_enabled: !ext.is_enabled });
sw.toast(`Extension ${ext.is_enabled ? 'disabled' : 'enabled'}`, 'success');
if (pkg.enabled) {
await sw.api.admin.packages.disable(pkg.id);
sw.toast(`${pkg.title || pkg.id} disabled`, 'success');
} else {
await sw.api.admin.packages.enable(pkg.id);
sw.toast(`${pkg.title || pkg.id} enabled`, 'success');
}
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteExt(id) {
const ok = await sw.confirm('Delete this extension?', true);
async function deletePkg(pkg) {
if (pkg.source === 'core') return;
const ok = await sw.confirm(`Delete "${pkg.title || pkg.id}"? This cannot be undone.`, true);
if (!ok) return;
try {
await sw.api.admin.extensions.del(id);
sw.toast('Extension deleted', 'success');
await sw.api.admin.packages.del(pkg.id);
sw.toast('Package deleted', 'success');
if (expandedId === pkg.id) { setExpandedId(null); setPkgSettings(null); }
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function tierBadge(tier) {
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
function uploadPackage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pkg,.surface,.zip';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
setInstalling(true);
try {
await sw.api.admin.packages.install(file);
sw.toast('Package installed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
finally { setInstalling(false); }
};
input.click();
}
function exportPkg(id) {
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
}
// ── Settings drawer ─────────────────────────
async function toggleSettings(pkgId) {
if (expandedId === pkgId) {
setExpandedId(null); setPkgSettings(null); setSettingsDraft({});
return;
}
try {
const data = await sw.api.admin.packages.settings(pkgId);
const settings = data?.data || {};
setPkgSettings(settings);
setSettingsDraft({ ...settings });
setExpandedId(pkgId);
} catch (e) {
sw.toast(e.message, 'error');
}
}
async function saveSettings(pkgId) {
try {
await sw.api.admin.packages.updateSettings(pkgId, settingsDraft);
sw.toast('Settings saved', 'success');
setPkgSettings({ ...settingsDraft });
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Registry ────────────────────────────────
async function toggleRegistry() {
if (registryOpen) { setRegistryOpen(false); return; }
setRegistryOpen(true);
setRegistryLoading(true);
try {
const data = await sw.api.admin.packages.registry();
setRegistryPkgs(data?.data || []);
} catch (e) {
sw.toast('Registry unavailable: ' + e.message, 'error');
setRegistryPkgs([]);
}
finally { setRegistryLoading(false); }
}
async function installFromRegistry(url) {
try {
await sw.api.admin.packages.registryInstall(url);
sw.toast('Package installed from registry', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Render ───────────────────────────────────
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const enabled = packages.filter(p => p.enabled).length;
const hasManifestSettings = (pkg) =>
pkg.manifest?.settings || pkg.package_settings;
return html`
<div>
${/* ── Stat cards ─────────────── */``}
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
</div>
${/* ── Action bar ─────────────── */``}
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
${TYPE_OPTIONS.map(t => html`
<button key=${t}
class="btn-small ${typeFilter === t ? 'btn-primary' : ''}"
onClick=${() => setTypeFilter(t)}
style="text-transform:capitalize;">${t}</button>
`)}
<span style="flex:1;" />
<button class="btn-small" onClick=${uploadPackage} disabled=${installing}>
${installing ? 'Installing\u2026' : 'Install Package'}
</button>
<button class="btn-small" onClick=${toggleRegistry}>
${registryOpen ? 'Close Registry' : 'Browse Registry'}
</button>
</div>
${/* ── Package list ────────────── */``}
<div class="admin-list">
${extensions.length === 0 && html`<div class="empty-hint">No extensions installed</div>`}
${extensions.map(ext => html`
<div class="admin-surface-row" key=${ext.id}>
<div style="flex:1;min-width:0;">
<strong>${ext.name}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${ext.version || '?'}</span>
${' '}${tierBadge(ext.tier)}
${ext.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
</div>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => toggleEnabled(ext)}>
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
${!ext.is_system && html`<button class="btn-small btn-danger" onClick=${() => deleteExt(ext.id)}>Delete</button>`}
${packages.length === 0 && html`<div class="empty-hint">No packages found</div>`}
${packages.map(pkg => html`
<div key=${pkg.id}>
<div class="admin-surface-row">
<div style="flex:1;min-width:0;">
<strong>${pkg.title || pkg.id}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${pkg.version || '?'}</span>
${' '}${typeBadge(pkg.type)}
${' '}${tierBadge(pkg.tier)}
${sourceBadge(pkg.source)}
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
</div>
<div class="admin-actions-cell">
<button class="btn-small"
onClick=${() => toggleEnabled(pkg)}
disabled=${CORE_IDS.has(pkg.id)}>
${pkg.enabled ? 'Disable' : 'Enable'}
</button>
${hasManifestSettings(pkg) && html`
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}>
${expandedId === pkg.id ? 'Close' : 'Settings'}
</button>
`}
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html`
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
`}
</div>
</div>
${/* ── Inline settings drawer ── */``}
${expandedId === pkg.id && pkgSettings != null && html`
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
${Object.keys(pkgSettings).length === 0
? html`<span class="text-muted" style="font-size:12px;">No configurable settings</span>`
: html`
${Object.entries(settingsDraft).map(([k, v]) => html`
<div key=${k} style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<label style="min-width:120px;font-size:12px;color:var(--text-muted);">${k}</label>
<input type="text"
value=${v ?? ''}
onInput=${e => setSettingsDraft(prev => ({ ...prev, [k]: e.target.value }))}
style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" />
</div>
`)}
<button class="btn-small" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button>
`
}
</div>
`}
</div>
`)}
</div>
${/* ── Registry panel ──────────── */``}
${registryOpen && html`
<div class="admin-section" style="margin-top:20px;padding:12px;border:1px solid var(--border);border-radius:6px;">
<h4 style="margin:0 0 8px;">Package Registry</h4>
${registryLoading
? html`<div class="settings-placeholder">Loading registry\u2026</div>`
: registryPkgs.length === 0
? html`<div class="empty-hint">No packages available in registry</div>`
: html`
<div class="admin-list">
${registryPkgs.map(rp => html`
<div class="admin-surface-row" key=${rp.id}>
<div style="flex:1;min-width:0;">
<strong>${rp.title || rp.id}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${rp.version || '?'}</span>
${rp.description && html`
<div class="text-muted" style="font-size:11px;margin-top:2px;">${rp.description}</div>
`}
</div>
<div class="admin-actions-cell">
${typeBadge(rp.type)}
<button class="btn-small btn-primary"
onClick=${() => installFromRegistry(rp.download_url)}>Install</button>
</div>
</div>
`)}
</div>
`
}
</div>
`}
</div>
`;
}

View File

@@ -21,9 +21,9 @@ export default function PersonasSection() {
sw.api.admin.models.list().catch(() => []),
sw.api.knowledge.list().catch(() => []),
]);
setPersonas(Array.isArray(p) ? p : p.data || []);
setModels(Array.isArray(m) ? m : m.models || m.data || []);
setKbs(Array.isArray(k) ? k : k.data || []);
setPersonas(p || []);
setModels(m || []);
setKbs(k.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -16,8 +16,8 @@ export default function ProvidersSection() {
sw.api.admin.configs.list(),
sw.api.admin.providers.types(),
]);
setConfigs(Array.isArray(c) ? c : c.configs || c.data || []);
setProviderTypes(t.types || t.data || []);
setConfigs(c || []);
setProviderTypes(t || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -82,7 +82,7 @@ export default function ProvidersSection() {
</div>
<div class="form-group"><label>Type</label>
<select name="provider">
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
${providerTypes.map(t => html`<option key=${t.id} value=${t.id} selected=${editing !== 'new' && editing.provider === t.id}>${t.display_name || t.name}</option>`)}
</select>
</div>
</div>

View File

@@ -5,7 +5,7 @@ const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function RolesSection() {
const [roles, setRoles] = useState([]);
const [roles, setRoles] = useState({});
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [dirty, setDirty] = useState({});
@@ -16,8 +16,8 @@ export default function RolesSection() {
sw.api.admin.roles.list(),
sw.api.admin.models.list().catch(() => []),
]);
setRoles(r.roles || r.data || (Array.isArray(r) ? r : []));
setModels(Array.isArray(m) ? m : m.models || m.data || []);
setRoles(r || {});
setModels(m || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -49,10 +49,8 @@ export default function RolesSection() {
return html`
<div>
${roles.length === 0 && html`<div class="empty-hint">No roles configured</div>`}
${roles.map(r => {
const name = typeof r === 'string' ? r : r.name || r.role;
const config = typeof r === 'object' ? r : {};
${Object.keys(roles).length === 0 && html`<div class="empty-hint">No roles configured</div>`}
${Object.entries(roles).map(([name, config]) => {
const changes = dirty[name] || {};
const primary = changes.primary_model ?? config.primary_model ?? '';
const fallback = changes.fallback_model ?? config.fallback_model ?? '';

View File

@@ -17,7 +17,7 @@ export default function RoutingSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.routing.policies();
setPolicies(Array.isArray(data) ? data : data.data || []);
setPolicies(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -18,31 +18,38 @@ export default function SettingsSection() {
sw.api.admin.models.list().catch(() => []),
sw.api.admin.vault.status().catch(() => null),
]);
const settings = s || {};
const raw = s || {};
const pol = raw.policies || {};
const cfg_ = raw.settings || {};
setCfg({
allow_registration: settings.policies?.allow_registration === 'true',
registration_default_state: settings.registration_default_state || 'pending',
system_prompt: settings.system_prompt || '',
default_model: settings.default_model || '',
allow_user_byok: settings.policies?.allow_user_byok === 'true',
allow_user_personas: settings.policies?.allow_user_personas === 'true',
allow_kb_direct_access: settings.policies?.allow_kb_direct_access === 'true',
banner_enabled: !!settings.banner?.enabled,
banner_text: settings.banner?.text || '',
banner_bg: settings.banner?.bg || '#007a33',
banner_fg: settings.banner?.fg || '#ffffff',
banner_position: settings.banner?.position || 'both',
search_provider: settings.search_provider || 'duckduckgo',
search_endpoint: settings.search_endpoint || '',
search_api_key: settings.search_api_key || '',
search_max_results: settings.search_max_results || 5,
compaction_enabled: !!settings.auto_compaction_enabled,
compaction_threshold: settings.compaction_threshold || 70,
compaction_cooldown: settings.compaction_cooldown || 30,
memory_extraction_enabled: !!settings.memory_extraction_enabled,
memory_auto_approve: !!settings.memory_auto_approve,
allow_registration: pol.allow_registration === 'true',
registration_default_state: cfg_.registration_default_state || 'pending',
system_prompt: cfg_.system_prompt || '',
default_model: pol.default_model || '',
allow_user_byok: pol.allow_user_byok === 'true',
allow_user_personas: pol.allow_user_personas === 'true',
allow_kb_direct_access: pol.allow_kb_direct_access === 'true' || pol.kb_direct_access === 'true',
banner_enabled: !!cfg_.banner?.enabled,
banner_text: cfg_.banner?.text || '',
banner_bg: cfg_.banner?.bg || '#007a33',
banner_fg: cfg_.banner?.fg || '#ffffff',
banner_position: cfg_.banner?.position || 'both',
message_enabled: !!cfg_.message?.enabled,
message_text: cfg_.message?.text || '',
message_variant: cfg_.message?.variant || 'info',
footer_enabled: !!cfg_.footer?.enabled,
footer_text: cfg_.footer?.text || '',
search_provider: cfg_.search_provider || 'duckduckgo',
search_endpoint: cfg_.search_endpoint || '',
search_api_key: cfg_.search_api_key || '',
search_max_results: cfg_.search_max_results || 5,
compaction_enabled: !!cfg_.auto_compaction_enabled,
compaction_threshold: cfg_.compaction_threshold || 70,
compaction_cooldown: cfg_.compaction_cooldown || 30,
memory_extraction_enabled: !!cfg_.memory_extraction_enabled,
memory_auto_approve: !!cfg_.memory_auto_approve,
});
setModels(Array.isArray(m) ? m : m.models || m.data || []);
setModels(m || []);
setVault(v);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
@@ -75,6 +82,19 @@ export default function SettingsSection() {
position: cfg.banner_position,
}});
// Message bar
await sw.api.admin.settings.update('message', { value: {
enabled: cfg.message_enabled,
text: cfg.message_text,
variant: cfg.message_variant,
}});
// Footer
await sw.api.admin.settings.update('footer', { value: {
enabled: cfg.footer_enabled,
text: cfg.footer_text,
}});
// Search
await sw.api.admin.settings.update('search_provider', { value: cfg.search_provider });
if (cfg.search_provider === 'searxng') {
@@ -154,6 +174,32 @@ export default function SettingsSection() {
`}
</div>
<div class="settings-section"><h3>Message Bar</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.message_enabled} onChange=${e => set('message_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show dismissible message bar</span></label>
${cfg.message_enabled && html`
<div style="margin-top:8px;">
<div class="form-group"><label>Text</label><input value=${cfg.message_text} onInput=${e => set('message_text', e.target.value)} placeholder="System maintenance scheduled..." /></div>
<div class="form-group"><label>Variant</label>
<select value=${cfg.message_variant} onChange=${e => set('message_variant', e.target.value)}>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="success">Success</option>
</select>
</div>
</div>
`}
</div>
<div class="settings-section"><h3>Footer</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.footer_enabled} onChange=${e => set('footer_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show footer bar</span></label>
${cfg.footer_enabled && html`
<div style="margin-top:8px;">
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Chat Switchboard" /></div>
</div>
`}
</div>
<div class="settings-section"><h3>Web Search</h3>
<div class="form-group"><label>Provider</label>
<select value=${cfg.search_provider} onChange=${e => set('search_provider', e.target.value)}>

View File

@@ -12,7 +12,7 @@ export default function TasksSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.tasks.list();
setTasks(Array.isArray(data) ? data : data.data || []);
setTasks(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -50,7 +50,7 @@ export default function TasksSection() {
}
try {
const data = await sw.api.tasks.runs(taskId);
const runs = Array.isArray(data) ? data : data.data || [];
const runs = data || [];
setExpandedRuns(prev => ({ ...prev, [taskId]: runs }));
} catch (e) { sw.toast(e.message, 'error'); }
}

View File

@@ -16,7 +16,7 @@ export default function TeamsSection() {
const loadTeams = useCallback(async () => {
try {
const data = await sw.api.admin.teams.list();
setTeams(Array.isArray(data) ? data : data.data || []);
setTeams(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -30,8 +30,8 @@ export default function TeamsSection() {
sw.api.admin.teams.members(team.id),
sw.api.admin.users.list(),
]);
setMembers(Array.isArray(m) ? m : m.data || []);
const uList = Array.isArray(u) ? u : u.users || [];
setMembers(m || []);
const uList = u || [];
setAllUsers(uList);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -66,7 +66,7 @@ export default function TeamsSection() {
sw.toast('Member added', 'success');
setShowAddMember(false);
const m = await sw.api.admin.teams.members(detail.id);
setMembers(Array.isArray(m) ? m : m.data || []);
setMembers(m || []);
} catch (e) { sw.toast(e.message, 'error'); }
}

View File

@@ -16,7 +16,7 @@ export default function UsageSection() {
sw.api.admin.pricing.list().catch(() => []),
]);
setUsage(u);
setPricing(Array.isArray(p) ? p : p.data || []);
setPricing(p || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -4,8 +4,6 @@
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
export default function UsersSection() {
const [users, setUsers] = useState([]);
const [search, setSearch] = useState('');
@@ -14,8 +12,8 @@ export default function UsersSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.admin.users.list();
setUsers(Array.isArray(data) ? data : data.users || []);
const resp = await sw.api.admin.users.list();
setUsers(resp.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -17,7 +17,7 @@ export default function WorkflowsSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(Array.isArray(data) ? data : data.data || []);
setWorkflows(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -0,0 +1,104 @@
// ==========================================
// Chat Surface — Channel Members Panel
// ==========================================
// Drawer showing channel participants with role management.
// Uses Drawer primitive + channels.participants API.
import { Drawer } from '../../primitives/drawer.js';
import { UserPicker } from '../../primitives/user-picker.js';
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ROLES = ['owner', 'member', 'observer'];
function _initials(name) {
if (!name) return '?';
return name.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
}
/**
* @param {{ open: boolean, channelId: string, onClose: () => void }} props
*/
export function ChannelMembersPanel({ open, channelId, onClose }) {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
if (!channelId) return;
setLoading(true);
try {
const data = await sw.api.channels.participants(channelId);
setMembers(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [channelId]);
useEffect(() => {
if (open && channelId) load();
}, [open, channelId, load]);
async function updateRole(pid, role) {
try {
await sw.api.channels.updateParticipant(channelId, pid, { role });
sw.toast('Role updated', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function remove(pid) {
const ok = await sw.confirm('Remove this participant?', true);
if (!ok) return;
try {
await sw.api.channels.removeParticipant(channelId, pid);
sw.toast('Participant removed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
const _onUserSelect = useCallback(async (user) => {
setAdding(true);
try {
await sw.api.channels.addParticipant(channelId, {
participant_type: 'user',
participant_id: user.id,
});
sw.toast('Participant added', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
finally { setAdding(false); }
}, [channelId, load]);
if (!open) return null;
return html`
<${Drawer} open=${open} side="right" width="340px" title="Members" onClose=${onClose}>
${loading && !members.length
? html`<div class="sw-panel-loading">Loading\u2026</div>`
: html`
<div class="sw-members-list">
${members.map(m => html`
<div class="sw-members-row" key=${m.id}>
<span class="sw-members-avatar">${_initials(m.display_name || m.participant_id)}</span>
<div class="sw-members-info">
<span class="sw-members-name">${m.display_name || m.participant_id}</span>
<span class="sw-members-type">${m.participant_type}</span>
</div>
<select class="sw-members-role" value=${m.role || 'member'}
onChange=${e => updateRole(m.id, e.target.value)}>
${ROLES.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
<button class="sw-members-remove" onClick=${() => remove(m.id)}
title="Remove">\u00d7</button>
</div>`)}
${members.length === 0 && html`
<div class="sw-panel-empty">No participants</div>`}
</div>
<div class="sw-members-add">
<${UserPicker}
onSelect=${_onUserSelect}
placeholder=${adding ? 'Adding\u2026' : 'Add participant\u2026'} />
</div>`}
<//>`;
}

View File

@@ -0,0 +1,108 @@
// ==========================================
// Chat Surface — Channel Settings Panel
// ==========================================
// Drawer for editing channel title, description, topic, ai_mode.
// Uses Drawer primitive + channels.update API.
import { Drawer } from '../../primitives/drawer.js';
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const AI_MODES = [
{ value: 'auto', label: 'Auto (always reply)' },
{ value: 'mention_only', label: 'Mention only' },
{ value: 'off', label: 'Off' },
];
/**
* @param {{ open: boolean, channelId: string, onClose: () => void }} props
*/
export function ChannelSettingsPanel({ open, channelId, onClose }) {
const [channel, setChannel] = useState(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ title: '', description: '', topic: '', ai_mode: 'auto' });
const load = useCallback(async () => {
if (!channelId) return;
setLoading(true);
try {
const data = await sw.api.channels.get(channelId);
setChannel(data);
setForm({
title: data.title || '',
description: data.description || '',
topic: data.topic || '',
ai_mode: data.ai_mode || 'auto',
});
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [channelId]);
useEffect(() => {
if (open && channelId) load();
}, [open, channelId, load]);
function _set(key, value) {
setForm(prev => ({ ...prev, [key]: value }));
}
async function save(e) {
e.preventDefault();
setSaving(true);
try {
await sw.api.channels.update(channelId, {
title: form.title || undefined,
description: form.description || undefined,
topic: form.topic || undefined,
ai_mode: form.ai_mode || undefined,
});
sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); }
}
if (!open) return null;
return html`
<${Drawer} open=${open} side="right" width="340px" title="Channel Settings" onClose=${onClose}>
${loading && !channel
? html`<div class="sw-panel-loading">Loading\u2026</div>`
: html`
<form class="sw-settings-form" onSubmit=${save}>
<div class="sw-settings-field">
<label>Title</label>
<input type="text" value=${form.title}
onInput=${e => _set('title', e.target.value)} />
</div>
<div class="sw-settings-field">
<label>Description</label>
<textarea rows="3" value=${form.description}
onInput=${e => _set('description', e.target.value)} />
</div>
<div class="sw-settings-field">
<label>Topic</label>
<input type="text" value=${form.topic}
onInput=${e => _set('topic', e.target.value)} />
</div>
<div class="sw-settings-field">
<label>AI Mode</label>
<select value=${form.ai_mode}
onChange=${e => _set('ai_mode', e.target.value)}>
${AI_MODES.map(m => html`
<option key=${m.value} value=${m.value}>${m.label}</option>`)}
</select>
</div>
${channel && html`
<div class="sw-settings-info">
<div><strong>Type:</strong> ${channel.channel_type || channel.type || '\u2014'}</div>
<div><strong>Created:</strong> ${channel.created_at ? new Date(channel.created_at).toLocaleDateString() : '\u2014'}</div>
</div>`}
<button type="submit" class="btn-md btn-primary sw-settings-save"
disabled=${saving}>
${saving ? 'Saving\u2026' : 'Save'}
</button>
</form>`}
<//>`;
}

View File

@@ -5,9 +5,14 @@
// ChatPane runs standalone=false — the surface manages navigation.
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
import { NotificationBell } from '../../shell/notification-bell.js';
import { ChannelMembersPanel } from './channel-members-panel.js';
import { ChannelSettingsPanel } from './channel-settings-panel.js';
import { UserPicker } from '../../primitives/user-picker.js';
const html = window.html;
const { useRef, useEffect } = window.hooks;
const { useState, useRef, useEffect, useCallback } = window.hooks;
const PANEL_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -16,6 +21,22 @@ const PANEL_SVG = html`
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
const PEOPLE_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>`;
const GEAR_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>`;
/**
* @param {{
* activeId: string|null,
@@ -28,10 +49,28 @@ const PANEL_SVG = html`
* }} props
*/
export function ChatWorkspace({
activeId, activeType, onChannelChange, onNewChat,
sidebarOpen, onToggleSidebar, toolsButton,
activeId, activeType, channelType, onChannelChange, onNewChat, onCreateChannel,
sidebarOpen, onToggleSidebar, toolsButton, sidebar,
}) {
const chatRef = useRef(null);
const [selectedModel, setSelectedModel] = useState(null);
const [membersOpen, setMembersOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const activeChannel = sidebar?.items?.find(c => c.id === activeId);
const showModelSelector = (activeChannel?.ai_mode || 'auto') !== 'off';
// Close panels on channel switch
useEffect(() => {
setMembersOpen(false);
setSettingsOpen(false);
}, [activeId]);
// Sync model selection into ChatPane's useChat
useEffect(() => {
if (selectedModel && chatRef.current?.setModel) {
chatRef.current.setModel(selectedModel);
}
}, [selectedModel]);
// When activeId changes, tell ChatPane to switch channel
useEffect(() => {
@@ -40,6 +79,29 @@ export function ChatWorkspace({
}
}, [activeId]);
// Dashboard view when no channel selected
if (!activeId) {
return html`
<div class="sw-chat-surface__workspace">
<div class="sw-chat-surface__workspace-header">
<div class="sw-chat-surface__workspace-header-left">
${!sidebarOpen && html`
<button class="sw-chat-surface__sidebar-toggle"
onClick=${onToggleSidebar}
title="Show sidebar"
aria-label="Show sidebar">
${PANEL_SVG}
</button>`}
</div>
<div class="sw-chat-surface__workspace-header-right">
<${NotificationBell} onNavigate=${onChannelChange} />
</div>
</div>
<${ChatDashboard} onNewChat=${onNewChat} onCreateChannel=${onCreateChannel}
items=${sidebar?.items} onNavigate=${onChannelChange} />
</div>`;
}
return html`
<div class="sw-chat-surface__workspace">
<div class="sw-chat-surface__workspace-header">
@@ -53,14 +115,115 @@ export function ChatWorkspace({
</button>`}
</div>
<div class="sw-chat-surface__workspace-header-right">
${showModelSelector && html`
<${ModelSelector}
value=${selectedModel}
onChange=${setSelectedModel} />`}
<button class="sw-chat-surface__header-btn"
onClick=${() => setMembersOpen(v => !v)}
title="Members"
aria-label="Members">
${PEOPLE_SVG}
</button>
<button class="sw-chat-surface__header-btn"
onClick=${() => setSettingsOpen(v => !v)}
title="Channel settings"
aria-label="Channel settings">
${GEAR_SVG}
</button>
<${NotificationBell} onNavigate=${onChannelChange} />
${toolsButton}
</div>
</div>
<${ChatPane}
channelId=${activeId}
standalone=${false}
modelId=${selectedModel}
handleRef=${chatRef}
onChannelChange=${onChannelChange}
className="sw-chat-surface__chat-pane" />
<${ChannelMembersPanel}
open=${membersOpen}
channelId=${activeId}
onClose=${() => setMembersOpen(false)} />
<${ChannelSettingsPanel}
open=${settingsOpen}
channelId=${activeId}
onClose=${() => setSettingsOpen(false)} />
</div>`;
}
// ── Dashboard (no chat selected) ────────────
function ChatDashboard({ onNewChat, onCreateChannel, items, onNavigate }) {
const recentItems = (items || []).slice(0, 8);
const [showDmPicker, setShowDmPicker] = useState(false);
async function _create(type) {
if (type === 'dm') {
setShowDmPicker(true);
return;
}
const label = type === 'channel' ? 'Channel' : 'Group';
const name = await window.sw.prompt(label + ' name:', '');
if (name && name.trim()) onCreateChannel(type, name.trim());
}
const _onDmSelect = useCallback((user) => {
setShowDmPicker(false);
const name = user.display_name || user.username;
onCreateChannel('dm', 'DM: ' + name, { participant: user.username || user.id });
}, [onCreateChannel]);
return html`
<div class="sw-chat-dashboard">
<div class="sw-chat-dashboard__welcome">
<h2 class="sw-chat-dashboard__title">Chat Switchboard</h2>
<p class="sw-chat-dashboard__hint">Start a conversation or browse your channels.</p>
</div>
<div class="sw-chat-dashboard__actions">
<button class="sw-chat-dashboard__card" onClick=${onNewChat}>
<span class="sw-chat-dashboard__card-icon">\u{1F4AC}</span>
<span class="sw-chat-dashboard__card-label">New AI Chat</span>
</button>
<button class="sw-chat-dashboard__card" onClick=${() => _create('channel')}>
<span class="sw-chat-dashboard__card-icon">#</span>
<span class="sw-chat-dashboard__card-label">New Channel</span>
</button>
<button class="sw-chat-dashboard__card" onClick=${() => _create('group')}>
<span class="sw-chat-dashboard__card-icon">\u{1F465}</span>
<span class="sw-chat-dashboard__card-label">New Group</span>
</button>
<button class="sw-chat-dashboard__card" onClick=${() => _create('dm')}>
<span class="sw-chat-dashboard__card-icon">\u{1F4E8}</span>
<span class="sw-chat-dashboard__card-label">Direct Message</span>
</button>
</div>
${showDmPicker && html`
<div class="sw-chat-dashboard__dm-picker">
<h3 class="sw-chat-dashboard__section-title">Start a DM</h3>
<${UserPicker}
onSelect=${_onDmSelect}
placeholder="Search for a user\u2026"
autoFocus=${true} />
<button class="btn-small"
style="margin-top: 8px;"
onClick=${() => setShowDmPicker(false)}>Cancel</button>
</div>`}
${recentItems.length > 0 && html`
<div class="sw-chat-dashboard__recent">
<h3 class="sw-chat-dashboard__section-title">Recent</h3>
<div class="sw-chat-dashboard__recent-list">
${recentItems.map(ch => html`
<div class="sw-chat-dashboard__recent-item" key=${ch.id}
onClick=${() => onNavigate?.(ch.id)}
style="cursor: pointer">
<span class="sw-chat-dashboard__recent-icon">
${ch.type === 'channel' || ch.type === 'group' ? '#' : '\u{1F4AC}'}
</span>
<span class="sw-chat-dashboard__recent-title">${ch.title || 'Untitled'}</span>
</div>`)}
</div>
</div>`}
</div>`;
}

View File

@@ -32,13 +32,45 @@ function ChatSurface() {
const tools = useTools();
const [toolsOpen, setToolsOpen] = useState(false);
const _onNewChat = useCallback(() => {
workspace.clearSelection();
}, [workspace.clearSelection]);
const _onNewChat = useCallback(async () => {
try {
const resp = await window.sw.api.channels.create({ type: 'direct', title: 'New Chat' });
const ch = resp;
if (ch?.id) {
sidebar.reload();
workspace.select(ch.id, 'direct');
}
} catch (err) {
console.error('[chat] New chat failed:', err);
window.sw?.toast?.('Failed to create chat', 'error');
}
}, [workspace.select, sidebar.reload]);
const _onCreateChannel = useCallback(async (type, title, opts) => {
try {
const body = { type, title };
if (opts?.participant) body.participants = [opts.participant];
const resp = await window.sw.api.channels.create(body);
const ch = resp;
if (ch?.id) {
sidebar.reload();
workspace.select(ch.id, type);
}
} catch (err) {
console.error('[chat] Create channel failed:', err);
window.sw?.toast?.('Failed to create ' + type, 'error');
}
}, [workspace.select, sidebar.reload]);
const _onDeleteChat = useCallback(async (id) => {
await sidebar.deleteChat(id);
if (id === workspace.activeId) workspace.clearSelection();
}, [sidebar.deleteChat, workspace.activeId, workspace.clearSelection]);
const _onChannelChange = useCallback((id) => {
if (id) workspace.selectChat(id);
}, [workspace.selectChat]);
if (id) workspace.select(id, 'direct');
else workspace.clearSelection();
}, [workspace.select, workspace.clearSelection]);
const _toggleSidebar = useCallback(() => {
workspace.setSidebarOpen(!workspace.sidebarOpen);
@@ -76,9 +108,10 @@ function ChatSurface() {
<${Sidebar}
sidebar=${sidebar}
activeId=${workspace.activeId}
onSelectChat=${workspace.selectChat}
onSelectChannel=${workspace.selectChannel}
onSelect=${workspace.select}
onNewChat=${_onNewChat}
onCreateChannel=${_onCreateChannel}
onDeleteChat=${_onDeleteChat}
onCollapse=${_toggleSidebar} />`}
${/* Main workspace */''}
@@ -86,11 +119,14 @@ function ChatSurface() {
<${ChatWorkspace}
activeId=${workspace.activeId}
activeType=${workspace.activeType}
channelType=${workspace.channelType}
onChannelChange=${_onChannelChange}
onNewChat=${_onNewChat}
onCreateChannel=${_onCreateChannel}
sidebarOpen=${workspace.sidebarOpen}
onToggleSidebar=${_toggleSidebar}
toolsButton=${toolsButton} />
toolsButton=${toolsButton}
sidebar=${sidebar} />
<${ToolsPopup}
open=${toolsOpen}
onClose=${_closeTools}

View File

@@ -1,65 +0,0 @@
// ==========================================
// Chat Surface — SidebarChannels Component
// ==========================================
// Collapsible channels section: DMs and named channels.
const html = window.html;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const HASH_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="9" x2="20" y2="9"/>
<line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/>
<line x1="16" y1="3" x2="14" y2="21"/>
</svg>`;
/**
* @param {{
* channels: Array<{id: string, title: string, unread_count?: number}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* }} props
*/
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
if (!channels.length && collapsed) return null;
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Channels</span>
${channels.length > 0 && html`
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${channels.length === 0 && html`
<div class="sw-chat-surface__empty">No channels</div>`}
${channels.map(ch => html`
<button key=${ch.id}
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
onClick=${() => onSelect(ch.id)}
role="option"
aria-selected=${ch.id === activeId}
title=${ch.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
${(ch.unread_count || 0) > 0 && html`
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
</button>`)}
</div>`}
</div>`;
}

View File

@@ -1,16 +1,13 @@
// ==========================================
// Chat Surface — SidebarChats Component
// Chat Surface — SidebarItems Component
// ==========================================
// Personal AI chats with folder grouping and context menus.
// Unified channel list with folder grouping, context menus,
// nested folders, and HTML5 drag-and-drop.
const html = window.html;
const { useState, useCallback, useRef, useEffect } = window.hooks;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
// ── Icons ────────────────────────────────
const CHAT_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -18,6 +15,15 @@ const CHAT_SVG = html`
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>`;
const HASH_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="9" x2="20" y2="9"/>
<line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/>
<line x1="16" y1="3" x2="14" y2="21"/>
</svg>`;
const FOLDER_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -30,31 +36,34 @@ const DOTS_SVG = html`
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
</svg>`;
/**
* @param {{
* chatsByFolder: Record<string, Array>,
* folders: Array<{id: string, name: string}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* onRename: (id: string, title: string) => void,
* onDelete: (id: string) => void,
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
* onCreateFolder: (name: string) => void,
* onRenameFolder: (id: string, name: string) => void,
* onDeleteFolder: (id: string) => void,
* }} props
*/
export function SidebarChats({
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
onRename, onDelete, onMoveToFolder, onCreateFolder,
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
function _iconForType(type) {
if (type === 'channel' || type === 'group') return HASH_SVG;
return CHAT_SVG;
}
// Max nesting depth for folders
const MAX_DEPTH = 3;
// ── Main Component ───────────────────────
export function SidebarItems({
itemsByFolder, folders, folderTree, activeId, onSelect,
onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder,
onRenameFolder, onDeleteFolder,
}) {
const [contextMenu, setContextMenu] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, type } of drop target
const [dragging, setDragging] = useState(false); // true while any drag in progress
const [collapsedFolders, setCollapsedFolders] = useState({});
const menuRef = useRef(null);
// Close context menu on outside click or Escape
// ── Context menu close ──────────────
useEffect(() => {
if (!contextMenu) return;
function _close(e) {
@@ -73,127 +82,290 @@ export function SidebarChats({
const _openMenu = useCallback((e, type, item) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
const scale = _getScale();
// The dots button may be display:none (zero rect) — fall back to parent
let rect = e.currentTarget.getBoundingClientRect();
if (!rect.width && !rect.height) {
const parent = e.currentTarget.closest('.sw-chat-surface__item, .sw-chat-surface__folder-header');
if (parent) rect = parent.getBoundingClientRect();
}
const x = rect.right / scale;
const y = rect.bottom / scale;
setContextMenu({ type, item, x, y });
}, []);
const _bodyContextMenu = useCallback((e) => {
if (e.target.closest('.sw-chat-surface__item') || e.target.closest('.sw-chat-surface__folder-header')) return;
e.preventDefault();
const scale = _getScale();
setContextMenu({ type: 'body', item: null, x: e.clientX / scale, y: e.clientY / scale });
}, []);
const _action = useCallback(async (action, item) => {
setContextMenu(null);
if (action === 'rename') {
const newTitle = prompt('Rename:', item.title || item.name || '');
const newTitle = await window.sw.prompt('Rename:', item.title || item.name || '');
if (newTitle && newTitle.trim()) {
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
else await onRename(item.id, newTitle.trim());
}
} else if (action === 'delete') {
const label = item.name !== undefined ? 'folder' : 'chat';
if (confirm('Delete this ' + label + '?')) {
const label = item.name !== undefined ? 'folder' : 'conversation';
const ok = await window.sw.confirm('Delete this ' + label + '?', true);
if (ok) {
if (item.name !== undefined) await onDeleteFolder(item.id);
else await onDelete(item.id);
}
} else if (action === 'move-out') {
await onMoveToFolder(item.id, null);
} else if (action === 'new-folder') {
const name = prompt('Folder name:');
const name = await window.sw.prompt('Folder name:', '');
if (name && name.trim()) await onCreateFolder(name.trim());
} else if (action === 'new-subfolder') {
const name = await window.sw.prompt('Subfolder name:', '');
if (name && name.trim()) await onCreateFolder(name.trim(), item.id);
} else if (action === 'unnest-folder') {
await onMoveFolderTo(item.id, null);
} else if (action.startsWith('move-to:')) {
const fid = action.slice(8);
await onMoveToFolder(item.id, fid);
}
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
}, [onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder]);
// Count total chats
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
const unfolderedChats = chatsByFolder[''] || [];
// ── Folder collapse toggle ──────────
const _toggleFolder = useCallback((folderId) => {
setCollapsedFolders(prev => ({ ...prev, [folderId]: !prev[folderId] }));
}, []);
// ── Drag & Drop ─────────────────────
const _onDragStart = useCallback((e, dragType, item) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ dragType, id: item.id }));
setDragging(true);
}, []);
const _onDragOver = useCallback((e, targetId, targetType) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOver({ id: targetId, type: targetType });
}, []);
const _onDragLeave = useCallback(() => {
setDragOver(null);
}, []);
const _onDropOnFolder = useCallback((e, folderId) => {
e.preventDefault();
setDragOver(null);
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.dragType === 'channel') {
onMoveToFolder(data.id, folderId);
} else if (data.dragType === 'folder' && data.id !== folderId) {
onMoveFolderTo(data.id, folderId);
}
} catch (_) {}
}, [onMoveToFolder, onMoveFolderTo]);
const _onDropOnRoot = useCallback((e) => {
e.preventDefault();
setDragOver(null);
setDragging(false);
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.dragType === 'channel') {
onMoveToFolder(data.id, null);
} else if (data.dragType === 'folder') {
onMoveFolderTo(data.id, null);
}
} catch (_) {}
}, [onMoveToFolder, onMoveFolderTo]);
const _onDragEnd = useCallback(() => {
setDragging(false);
setDragOver(null);
}, []);
// ── Render ───────────────────────────
const unfolderedItems = itemsByFolder[''] || [];
const totalItems = Object.values(itemsByFolder).reduce((sum, arr) => sum + arr.length, 0);
const tree = folderTree || [];
const rootDropActive = dragging && dragOver?.id === '__root' && dragOver?.type === 'root';
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
<div class="sw-chat-surface__section-body"
role="listbox"
onContextMenu=${_bodyContextMenu}
onDragOver=${(e) => { e.preventDefault(); }}
onDrop=${_onDropOnRoot}
onDragEnd=${_onDragEnd}>
${/* Render folder tree recursively */''}
${tree.map(node => html`
<${FolderNode}
key=${'f-' + node.id}
node=${node}
depth=${0}
itemsByFolder=${itemsByFolder}
activeId=${activeId}
collapsedFolders=${collapsedFolders}
dragOver=${dragOver}
onSelect=${onSelect}
onOpenMenu=${_openMenu}
onToggleFolder=${_toggleFolder}
onDragStart=${_onDragStart}
onDragOver=${_onDragOver}
onDragLeave=${_onDragLeave}
onDropOnFolder=${_onDropOnFolder} />`)}
${/* Unfoldered items */''}
${unfolderedItems.map(ch => html`
<${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu}
onDragStart=${_onDragStart} />`)}
${totalItems === 0 && tree.length === 0 && html`
<div class="sw-chat-surface__empty">No conversations yet</div>`}
${/* Visible root drop zone — shown during drag */''}
${dragging && tree.length > 0 && html`
<div class=${'sw-chat-surface__root-drop' + (rootDropActive ? ' sw-chat-surface__root-drop--active' : '')}
onDragOver=${(e) => _onDragOver(e, '__root', 'root')}
onDragLeave=${_onDragLeave}
onDrop=${_onDropOnRoot}>
Drop here to remove from folder
</div>`}
</div>
${/* Context Menu */''}
${contextMenu && html`
<div class="sw-chat-surface__context-menu" ref=${menuRef}
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
${contextMenu.type === 'chat' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
${folders.length > 0 && folders.map(f => html`
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
Move to ${f.name}
</button>`)}
${contextMenu.item.folder_id && html`
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
${contextMenu.type === 'folder' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
<button onClick=${() => _action('new-subfolder', contextMenu.item)}>New subfolder</button>
${contextMenu.item.parent_id && html`
<button onClick=${() => _action('unnest-folder', contextMenu.item)}>Move to root</button>`}
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
${contextMenu.type === 'body' && html`
<button onClick=${() => _action('new-folder', null)}>New Folder</button>
`}
</div>`}
`;
}
// ── FolderNode (recursive) ───────────────
function FolderNode({
node, depth, itemsByFolder, activeId, collapsedFolders, dragOver,
onSelect, onOpenMenu, onToggleFolder,
onDragStart, onDragOver, onDragLeave, onDropOnFolder,
}) {
const folderItems = itemsByFolder[node.id] || [];
const totalCount = folderItems.length + (node.children || []).length;
const isCollapsed = !!collapsedFolders[node.id];
const isDragTarget = dragOver?.id === node.id && dragOver?.type === 'folder';
const hasChildren = (node.children?.length > 0) || folderItems.length > 0;
const indent = depth * 12;
return html`
<div key=${'f-' + node.id}
class=${'sw-chat-surface__folder' + (isDragTarget ? ' sw-chat-surface__folder--drag-over' : '')}
style=${indent ? 'padding-left:' + indent + 'px' : ''}
draggable=${depth < MAX_DEPTH}
onDragStart=${(e) => { e.stopPropagation(); onDragStart(e, 'folder', node); }}
onDragOver=${(e) => { if (depth < MAX_DEPTH - 1) onDragOver(e, node.id, 'folder'); }}
onDragLeave=${onDragLeave}
onDrop=${(e) => { e.stopPropagation(); onDropOnFolder(e, node.id); }}>
<div class="sw-chat-surface__folder-header"
onClick=${() => onToggleFolder(node.id)}>
<span class=${'sw-chat-surface__chevron' + (isCollapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Chats</span>
${totalChats > 0 && html`
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${/* Folders first */''}
${folders.map(folder => {
const folderChats = chatsByFolder[folder.id] || [];
return html`
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
<div class="sw-chat-surface__folder-header">
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-chat-surface__folder-name">${folder.name}</span>
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _openMenu(e, 'folder', folder)}
title="Folder actions"
aria-label="Folder actions">
${DOTS_SVG}
</button>
</div>
${folderChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu}
indent=${true} />`)}
</div>`;
})}
${/* Unfoldered chats */''}
${unfolderedChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
${totalChats === 0 && html`
<div class="sw-chat-surface__empty">No chats yet</div>`}
</div>`}
${/* Context Menu */''}
${contextMenu && html`
<div class="sw-chat-surface__context-menu" ref=${menuRef}
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
${contextMenu.type === 'chat' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
${folders.length > 0 && folders.map(f => html`
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
Move to ${f.name}
</button>`)}
${contextMenu.item.folder_id && html`
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
${contextMenu.type === 'folder' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
</div>`}
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-chat-surface__folder-name">${node.name}</span>
<span class="sw-chat-surface__folder-count">${totalCount}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'folder', node); }}
title="Folder actions"
aria-label="Folder actions">
${DOTS_SVG}
</button>
</div>
${!isCollapsed && html`
${/* Child folders */''}
${(node.children || []).map(child => html`
<${FolderNode}
key=${'f-' + child.id}
node=${child}
depth=${depth + 1}
itemsByFolder=${itemsByFolder}
activeId=${activeId}
collapsedFolders=${collapsedFolders}
dragOver=${dragOver}
onSelect=${onSelect}
onOpenMenu=${onOpenMenu}
onToggleFolder=${onToggleFolder}
onDragStart=${onDragStart}
onDragOver=${onDragOver}
onDragLeave=${onDragLeave}
onDropOnFolder=${onDropOnFolder} />`)}
${/* Channel items in this folder */''}
${folderItems.map(ch => html`
<${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${onOpenMenu}
onDragStart=${onDragStart}
indent=${true} />`)}
`}
</div>`;
}
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
// ── ChannelItem ──────────────────────────
function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) {
const icon = _iconForType(channel.type);
return html`
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
onClick=${() => onSelect(chat.id)}
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
<button class=${'sw-chat-surface__item' + (channel.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
onClick=${() => onSelect(channel.id, channel.type)}
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', channel); }}
draggable=${true}
onDragStart=${(e) => onDragStart(e, 'channel', channel)}
role="option"
aria-selected=${chat.id === activeId}
title=${chat.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
aria-selected=${channel.id === activeId}
title=${channel.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${icon}</span>
<span class="sw-chat-surface__item-title">${channel.title || 'Untitled'}</span>
${(channel.unread_count || 0) > 0 && html`
<span class="sw-chat-surface__unread">${channel.unread_count}</span>`}
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
title="Chat actions"
aria-label="Chat actions">
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'chat', channel); }}
title="Actions"
aria-label="Actions">
${DOTS_SVG}
</button>
</button>`;
}
function _stopAndOpen(e, onOpenMenu, type, item) {
e.stopPropagation();
onOpenMenu(e, type, item);
// Read the CSS scale transform on .surface-inner (set by appearance zoom).
// Context menus use position:fixed which breaks under transforms,
// so we divide getBoundingClientRect values by scale to compensate.
function _getScale() {
const el = document.getElementById('surfaceInner');
if (!el) return 1;
const t = getComputedStyle(el).transform;
if (!t || t === 'none') return 1;
const m = t.match(/matrix\(([^,]+)/);
return m ? parseFloat(m[1]) || 1 : 1;
}
// Folder count = direct children only (subfolders + channels in this folder).

View File

@@ -1,15 +1,14 @@
// ==========================================
// Chat Surface — Sidebar Component
// ==========================================
// Search bar, new chat, channels section, chats section with folders.
// Search bar, new chat, unified channel list with folders.
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
import { SidebarChannels } from './sidebar-channels.js';
import { SidebarChats } from './sidebar-chats.js';
import { SidebarItems } from './sidebar-chats.js';
import { UserMenu } from '../../shell/user-menu.js';
const html = window.html;
const { useCallback } = window.hooks;
const { useState, useCallback, useRef, useEffect } = window.hooks;
const PLUS_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -32,31 +31,98 @@ const COLLAPSE_SVG = html`
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
const FOLDER_PLUS_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>`;
/**
* @param {{
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
* activeId: string|null,
* onSelectChat: (id: string) => void,
* onSelectChannel: (id: string) => void,
* onSelect: (id: string, type: string) => void,
* onNewChat: () => void,
* onCollapse: () => void,
* }} props
*/
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
export function Sidebar({ sidebar, activeId, onSelect, onNewChat, onCreateChannel, onDeleteChat, onCollapse }) {
const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef(null);
const _onSearch = useCallback((e) => {
sidebar.setSearch(e.target.value);
}, [sidebar.setSearch]);
const _onNewFolder = useCallback(async () => {
const name = await window.sw.prompt('Folder name:', '');
if (name && name.trim()) sidebar.createFolder(name.trim());
}, [sidebar.createFolder]);
// Close new-channel dropdown on outside click / Escape
useEffect(() => {
if (!newMenuOpen) return;
function _close(e) {
if (e.type === 'keydown' && e.key !== 'Escape') return;
if (e.type === 'click' && newMenuRef.current?.contains(e.target)) return;
setNewMenuOpen(false);
}
document.addEventListener('click', _close);
document.addEventListener('keydown', _close);
return () => {
document.removeEventListener('click', _close);
document.removeEventListener('keydown', _close);
};
}, [newMenuOpen]);
const _newChannel = useCallback(async (type) => {
setNewMenuOpen(false);
if (type === 'direct') {
onNewChat();
} else if (type === 'dm') {
const who = await window.sw.prompt('Username to DM:', '');
if (who && who.trim()) onCreateChannel('dm', 'DM: ' + who.trim(), { participant: who.trim() });
} else {
const name = await window.sw.prompt((type === 'channel' ? 'Channel' : 'Group') + ' name:', '');
if (name && name.trim()) onCreateChannel(type, name.trim());
}
}, [onNewChat, onCreateChannel]);
return html`
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
${/* Header: new chat + collapse */''}
<div class="sw-chat-surface__sidebar-header">
<button class="sw-chat-surface__new-chat-btn"
onClick=${onNewChat}
title="New chat"
aria-label="Start new chat">
${PLUS_SVG}
<span>New Chat</span>
<div class="sw-chat-surface__new-menu-wrap" ref=${newMenuRef}>
<button class="sw-chat-surface__new-chat-btn"
onClick=${() => setNewMenuOpen(!newMenuOpen)}
title="New conversation"
aria-label="New conversation">
${PLUS_SVG}
<span>New</span>
</button>
${newMenuOpen && html`
<div class="sw-chat-surface__new-menu">
<button onClick=${() => _newChannel('direct')}>
<span class="sw-chat-surface__new-menu-icon">\u{1F4AC}</span> AI Chat
</button>
<button onClick=${() => _newChannel('channel')}>
<span class="sw-chat-surface__new-menu-icon">#</span> Channel
</button>
<button onClick=${() => _newChannel('group')}>
<span class="sw-chat-surface__new-menu-icon">\u{1F465}</span> Group
</button>
<button onClick=${() => _newChannel('dm')}>
<span class="sw-chat-surface__new-menu-icon">\u{1F4E8}</span> Direct Message
</button>
</div>`}
</div>
<button class="sw-chat-surface__new-folder-btn"
onClick=${_onNewFolder}
title="New folder"
aria-label="Create new folder">
${FOLDER_PLUS_SVG}
</button>
<button class="sw-chat-surface__collapse-btn"
onClick=${onCollapse}
@@ -79,23 +145,16 @@ export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNe
${/* Scrollable content */''}
<div class="sw-chat-surface__sidebar-body">
<${SidebarChannels}
channels=${sidebar.channels}
activeId=${activeId}
collapsed=${sidebar.collapsed.channels}
onToggle=${() => sidebar.toggleCollapsed('channels')}
onSelect=${onSelectChannel} />
<${SidebarChats}
chatsByFolder=${sidebar.chatsByFolder}
<${SidebarItems}
itemsByFolder=${sidebar.itemsByFolder}
folders=${sidebar.folders}
folderTree=${sidebar.folderTree}
activeId=${activeId}
collapsed=${sidebar.collapsed.chats}
onToggle=${() => sidebar.toggleCollapsed('chats')}
onSelect=${onSelectChat}
onSelect=${onSelect}
onRename=${sidebar.renameChat}
onDelete=${sidebar.deleteChat}
onDelete=${onDeleteChat || sidebar.deleteChat}
onMoveToFolder=${sidebar.moveToFolder}
onMoveFolderTo=${sidebar.moveFolderTo}
onCreateFolder=${sidebar.createFolder}
onRenameFolder=${sidebar.renameFolder}
onDeleteFolder=${sidebar.deleteFolder} />

View File

@@ -19,14 +19,9 @@ export function useTools() {
useEffect(() => {
// Load available tools from API
if (window.sw?.api?.admin?.tools?.list) {
window.sw.api.admin.tools.list().then(resp => {
setTools(resp?.data || resp || []);
}).catch(() => {});
} else if (window.sw?.api?.channels?.tools) {
// Fallback — try the channels tools endpoint
window.sw.api.channels.tools?.().then(resp => {
setTools(resp?.data || resp || []);
if (window.sw?.api?.tools?.list) {
window.sw.api.tools.list().then(resp => {
setTools(resp || []);
}).catch(() => {});
}
}, []);

View File

@@ -1,11 +1,10 @@
// ==========================================
// Chat Surface — useSidebar Hook
// ==========================================
// Loads and manages sidebar data: channels (DMs + named),
// personal chats, and folders. Subscribes to WS events
// for live updates.
// Loads and manages sidebar data: all channels (unified)
// and folders. Subscribes to WS events for live updates.
const { useState, useEffect, useCallback, useMemo } = window.hooks;
const { useState, useEffect, useCallback, useMemo, useRef } = window.hooks;
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
@@ -15,12 +14,13 @@ const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
export function useSidebar(opts = {}) {
const { activeId } = opts;
const [channels, setChannels] = useState([]); // named channels + DMs
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
const [allChannels, setAllChannels] = useState([]);
const [folders, setFolders] = useState([]);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(_loadCollapsed);
const [loading, setLoading] = useState(true);
const activeIdRef = useRef(activeId);
activeIdRef.current = activeId;
// ── Load data ───────────────────────────
const reload = useCallback(async () => {
@@ -31,17 +31,19 @@ export function useSidebar(opts = {}) {
? window.sw.api.folders.list().catch(() => [])
: Promise.resolve([]);
const [chResp, dmResp, fResp] = await Promise.all([
// Named channels (group chats, DMs with participants)
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
// Personal AI chats
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
// Folders
const [allResp, fResp] = await Promise.all([
window.sw.api.channels.list({ page: 1, per_page: 200, types: 'direct,dm,group,channel' }).catch(() => []),
foldersPromise,
]);
setChannels(_extract(chResp));
setChats(_extract(dmResp));
const fresh = _extract(allResp);
// Zero unread for the active channel (mark-read already fired)
const aid = activeIdRef.current;
if (aid) {
const idx = fresh.findIndex(c => c.id === aid);
if (idx !== -1) fresh[idx] = { ...fresh[idx], unread_count: 0 };
}
setAllChannels(fresh);
setFolders(_extract(fResp));
} catch (_) {}
@@ -50,6 +52,16 @@ export function useSidebar(opts = {}) {
useEffect(() => { reload(); }, [reload]);
// ── Clear unread badge when channel becomes active ──
useEffect(() => {
if (!activeId) return;
setAllChannels(prev => prev.map(c =>
c.id === activeId && c.unread_count > 0
? { ...c, unread_count: 0 }
: c
));
}, [activeId]);
// ── WebSocket live updates ──────────────
useEffect(() => {
if (!window.sw?.on) return;
@@ -70,31 +82,42 @@ export function useSidebar(opts = {}) {
}, [reload]);
// ── Search filter ───────────────────────
const filteredChannels = useMemo(() => {
if (!search) return channels;
const filtered = useMemo(() => {
if (!search) return allChannels;
const q = search.toLowerCase();
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
}, [channels, search]);
return allChannels.filter(c => (c.title || '').toLowerCase().includes(q));
}, [allChannels, search]);
const filteredChats = useMemo(() => {
if (!search) return chats;
const q = search.toLowerCase();
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
}, [chats, search]);
// Group chats by folder
const chatsByFolder = useMemo(() => {
// Group all channels by folder
const itemsByFolder = useMemo(() => {
const map = { '': [] };
for (const f of (folders || [])) map[f.id] = [];
for (const c of filteredChats) {
for (const c of filtered) {
const fid = c.folder_id || '';
if (!map[fid]) map[fid] = [];
map[fid].push(c);
}
return map;
}, [filteredChats, folders]);
}, [filtered, folders]);
// ── Collapse sections ───────────────────
// Build nested folder tree (max 3 levels)
const folderTree = useMemo(() => {
if (!folders || !folders.length) return [];
const byId = {};
for (const f of folders) byId[f.id] = { ...f, children: [] };
const roots = [];
for (const f of folders) {
const node = byId[f.id];
if (f.parent_id && byId[f.parent_id]) {
byId[f.parent_id].children.push(node);
} else {
roots.push(node);
}
}
return roots;
}, [folders]);
// ── Collapse folders ────────────────────
const toggleCollapsed = useCallback((key) => {
setCollapsed(prev => {
const next = { ...prev, [key]: !prev[key] };
@@ -104,9 +127,15 @@ export function useSidebar(opts = {}) {
}, []);
// ── CRUD helpers ────────────────────────
const createFolder = useCallback(async (name) => {
const createFolder = useCallback(async (name, parentId) => {
if (!window.sw?.api?.folders?.create) return;
await window.sw.api.folders.create(name);
await window.sw.api.folders.create(name, parentId ? { parent_id: parentId } : undefined);
reload();
}, [reload]);
const moveFolderTo = useCallback(async (folderId, parentId) => {
if (!window.sw?.api?.folders?.update) return;
await window.sw.api.folders.update(folderId, { parent_id: parentId || null });
reload();
}, [reload]);
@@ -136,10 +165,10 @@ export function useSidebar(opts = {}) {
}, [reload]);
return {
channels: filteredChannels,
chats: filteredChats,
chatsByFolder,
items: filtered,
itemsByFolder,
folders,
folderTree,
search,
setSearch,
collapsed,
@@ -151,6 +180,7 @@ export function useSidebar(opts = {}) {
renameChat,
deleteChat,
moveToFolder,
moveFolderTo,
renameFolder,
deleteFolder,
};

View File

@@ -24,42 +24,47 @@ export function useWorkspace() {
const stored = _loadStored();
const [activeId, setActiveId] = useState(stored.id);
const [activeType, setActiveType] = useState(stored.type);
const [channelType, setChannelType] = useState(stored.channelType || null);
const [sidebarOpen, setSidebarOpen] = useState(true);
// Persist on change
useEffect(() => {
if (activeId) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType, channelType }));
} catch (_) {}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}, [activeId, activeType]);
}, [activeId, activeType, channelType]);
const selectChat = useCallback((id) => {
const select = useCallback((id, chType) => {
setActiveId(id);
setActiveType('chat');
setChannelType(chType || 'direct');
// Map channel model type to workspace type
const wsType = (chType === 'channel' || chType === 'group') ? 'channel' : 'chat';
setActiveType(wsType);
// Auto-close sidebar on mobile
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const selectChannel = useCallback((id) => {
setActiveId(id);
setActiveType('channel');
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
// Keep legacy aliases for any other callers
const selectChat = useCallback((id) => select(id, 'direct'), [select]);
const selectChannel = useCallback((id) => select(id, 'channel'), [select]);
const clearSelection = useCallback(() => {
setActiveId(null);
setActiveType(null);
setChannelType(null);
}, []);
return {
activeId,
activeType,
channelType,
sidebarOpen,
setSidebarOpen,
select,
selectChat,
selectChannel,
clearSelection,
@@ -71,8 +76,8 @@ function _loadStored() {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const obj = JSON.parse(raw);
return { id: obj.id || null, type: obj.type || null };
return { id: obj.id || null, type: obj.type || null, channelType: obj.channelType || null };
}
} catch (_) {}
return { id: null, type: null };
return { id: null, type: null, channelType: null };
}

View File

@@ -7,6 +7,7 @@
// v0.37.11
import { NotesPane } from '../../components/notes-pane/index.js';
import { NotificationBell } from '../../shell/notification-bell.js';
const html = window.html;
@@ -41,7 +42,9 @@ export function NotesWorkspace({
${PANEL_SVG}
</button>`}
</div>
<div class="sw-notes-surface__workspace-header-right" />
<div class="sw-notes-surface__workspace-header-right">
<${NotificationBell} />
</div>
</div>
<${NotesPane}
standalone=${false}

View File

@@ -33,9 +33,22 @@ export function AppearanceSection() {
p.msgFont = msgFont;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Apply zoom + font size
document.documentElement.style.zoom = scale !== 100 ? (scale / 100) : '';
document.documentElement.style.setProperty('--msg-font-size', msgFont + 'px');
// Apply scale to content area only (not shell/nav)
const inner = document.getElementById('surfaceInner');
if (inner) {
if (scale !== 100) {
const s = scale / 100;
inner.style.transform = `scale(${s})`;
inner.style.transformOrigin = 'top left';
inner.style.width = (100 / s) + '%';
inner.style.height = (100 / s) + '%';
} else {
inner.style.transform = '';
inner.style.width = '';
inner.style.height = '';
}
}
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
}, [scale, msgFont]);

View File

@@ -27,7 +27,7 @@ export function GeneralSection() {
temperature: sObj.temperature ?? 0.7,
show_thinking: sObj.show_thinking || false,
});
const modelList = (m?.data || m || []).filter(x => !x.hidden);
const modelList = (m.data || []).filter(x => !x.hidden);
setModels(modelList);
// Set initial max hint

View File

@@ -17,7 +17,7 @@ export default function GitKeysSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.git.credentials.list();
setKeys(Array.isArray(data) ? data : data.data || []);
setKeys(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -25,8 +25,8 @@ export default function KnowledgeSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.knowledge.list();
setKbs(Array.isArray(data) ? data : data.data || []);
const resp = await sw.api.knowledge.list();
setKbs(resp.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
@@ -37,7 +37,7 @@ export default function KnowledgeSection() {
setDetail(kb);
try {
const d = await sw.api.knowledge.documents(kb.id);
setDocs(Array.isArray(d) ? d : d.data || []);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -80,7 +80,7 @@ export default function KnowledgeSection() {
await sw.api.knowledge.upload(detail.id, file);
sw.toast('Document uploaded', 'success');
const d = await sw.api.knowledge.documents(detail.id);
setDocs(Array.isArray(d) ? d : d.data || []);
setDocs(d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
};
input.click();

View File

@@ -27,7 +27,7 @@ export default function MemorySection() {
sw.api.memory.list(),
sw.api.memory.count().catch(() => null),
]);
setMemories(Array.isArray(data) ? data : data.data || []);
setMemories(data || []);
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }

View File

@@ -20,10 +20,10 @@ export function ModelsSection() {
sw.api.models.enabled(),
sw.api.models.preferences(),
]);
setModels(m?.data || m || []);
setModels(m.data || []);
// Build prefs map: model_id → { hidden }
const map = {};
(p?.data || p || []).forEach(x => { map[x.model_id] = x; });
(p || []).forEach(x => { map[x.model_id] = x; });
setPrefs(map);
} catch (e) {
console.warn('[settings/models]', e.message);

View File

@@ -16,7 +16,7 @@ export function PersonasSection() {
const load = useCallback(async () => {
try {
const resp = await sw.api.personas.list();
setPersonas(resp?.data || resp || []);
setPersonas(resp || []);
} catch (e) {
setPersonas([]);
}

View File

@@ -16,7 +16,7 @@ export function ProvidersSection() {
const load = useCallback(async () => {
try {
const resp = await sw.api.providers.list();
setProviders(resp?.configs || resp?.data || resp || []);
setProviders(resp || []);
} catch (e) {
setProviders([]);
}

View File

@@ -26,10 +26,10 @@ export function RolesSection() {
sw.api.models.enabled(),
sw.api.profile.settings(),
]);
const provs = (provResp?.configs || provResp?.data || [])
const provs = (provResp || [])
.filter(c => c.scope === 'personal');
setProviders(provs);
setModels(modResp?.data || modResp || []);
setModels(modResp.data || []);
// Extract role overrides from settings
const overrides = settings?.model_roles || {};

View File

@@ -16,7 +16,7 @@ export default function TasksSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.tasks.list();
setTasks(Array.isArray(data) ? data : data.data || []);
setTasks(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -11,7 +11,7 @@ export function TeamsSection() {
useEffect(() => {
sw.api.teams.mine().then(resp => {
setTeams(resp?.data || resp || []);
setTeams(resp || []);
}).catch(() => setTeams([]));
}, []);

View File

@@ -13,7 +13,7 @@ export default function WorkflowsSection() {
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(Array.isArray(data) ? data : data.data || []);
setWorkflows(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);

View File

@@ -11,7 +11,7 @@ export default function GroupsSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.groups(teamId);
setGroups(Array.isArray(data) ? data : data.data || []);
setGroups(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);

View File

@@ -11,7 +11,7 @@ export default function KnowledgeSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.knowledgeBases(teamId);
setKbs(Array.isArray(data) ? data : data.data || []);
setKbs(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);

View File

@@ -13,7 +13,7 @@ export default function MembersSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.members(teamId);
setMembers(Array.isArray(data) ? data : data.data || []);
setMembers(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
@@ -23,8 +23,8 @@ export default function MembersSection({ teamId }) {
async function openAdd() {
setShowAdd(true);
try {
const data = await sw.api.admin.users.list();
setUsers(Array.isArray(data) ? data : data.users || data.data || []);
const resp = await sw.api.admin.users.list();
setUsers(resp.data || []);
} catch {
// non-admin may not have access — leave empty
setUsers([]);

View File

@@ -12,7 +12,7 @@ export default function PersonasSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.personas(teamId);
setPersonas(Array.isArray(data) ? data : data.data || []);
setPersonas(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);

View File

@@ -16,8 +16,8 @@ export default function ProvidersSection({ teamId }) {
sw.api.teams.providers(teamId),
sw.api.admin.providers.types().catch(() => ({ types: [] })),
]);
setConfigs(Array.isArray(c) ? c : c.data || []);
setProviderTypes(t.types || t.data || []);
setConfigs(c.data || []);
setProviderTypes(t || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);

View File

@@ -12,7 +12,7 @@ export default function TasksSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.tasks(teamId);
setTasks(Array.isArray(data) ? data : data.data || []);
setTasks(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);

View File

@@ -16,7 +16,7 @@ export default function WorkflowsSection({ teamId }) {
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflows(teamId);
setWorkflows(Array.isArray(data) ? data : data.data || []);
setWorkflows(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
@@ -27,7 +27,7 @@ export default function WorkflowsSection({ teamId }) {
setEditing(wf);
try {
const detail = await sw.api.teams.workflows(teamId);
const match = (Array.isArray(detail) ? detail : detail.data || []).find(w => w.id === wf.id);
const match = (detail || []).find(w => w.id === wf.id);
setStages(match?.stages || wf.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}