Changeset 0.37.16 (#228)

This commit is contained in:
2026-03-24 11:46:13 +00:00
parent d005e8a30f
commit e0687d2ea6
19 changed files with 2900 additions and 13 deletions

View File

@@ -0,0 +1,429 @@
/**
* ProjectDetail — two-column detail view with inline chat + metadata sidebar.
*
* Props:
* project — project object
* onBack — () => void (return to list)
* onUpdate — (deleted?: boolean) => void
*
* Left panel toggles: conversation list ↔ embedded ChatPane.
* Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files).
*/
const html = window.html;
const h = window.preact.h;
const { useState, useEffect, useCallback, useRef } = window.hooks;
import { ChatPane } from '../../components/chat-pane/index.js';
import { UserMenu } from '../../shell/user-menu.js';
const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6'];
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';
}
function relTime(dateStr) {
if (!dateStr) return '';
const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']];
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (val < 10) return 'just now';
for (const [div, unit] of DIVS) {
if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; }
val /= div;
}
return '';
}
export function ProjectDetail({ project, onBack, onUpdate }) {
const BASE = window.__BASE__ || '';
const chatRef = useRef(null);
// ── State ──
const [activeChannelId, setActiveChannelId] = useState(null);
const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] });
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState(project.name || '');
const [menuOpen, setMenuOpen] = useState(false);
const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false });
const [desc, setDesc] = useState(project.description || '');
const [instructions, setInstructions] = useState(project.settings?.instructions || '');
const [descDirty, setDescDirty] = useState(false);
const [instrDirty, setInstrDirty] = useState(false);
const [starred, setStarred] = useState(!!project.settings?.starred);
// Picker state
const [pickerOpen, setPickerOpen] = useState('');
const [pickerItems, setPickerItems] = useState([]);
const [pickerLoading, setPickerLoading] = useState(false);
// Drag state for files
const [dragActive, setDragActive] = useState(false);
// ── Load associations ──
const loadAssoc = useCallback(async () => {
const id = project.id;
const [ch, kb, nt, fi] = await Promise.all([
sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []),
sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []),
sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []),
sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []),
]);
setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi });
}, [project.id]);
useEffect(() => { loadAssoc(); }, [loadAssoc]);
// ── Toggle section ──
function toggleSection(key) {
setSections(prev => ({ ...prev, [key]: !prev[key] }));
}
// ── Name editing ──
async function saveName() {
const name = nameVal.trim();
if (!name || name === project.name) { setEditingName(false); return; }
try {
await sw.api.projects.update(project.id, { name });
sw.toast('Renamed', 'success');
setEditingName(false);
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Description / Instructions save ──
async function saveDesc() {
try {
await sw.api.projects.update(project.id, { description: desc });
sw.toast('Description saved', 'success');
setDescDirty(false);
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function saveInstructions() {
try {
const settings = { ...(project.settings || {}), instructions };
await sw.api.projects.update(project.id, { settings });
sw.toast('Instructions saved', 'success');
setInstrDirty(false);
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Star toggle ──
async function toggleStar() {
const next = !starred;
setStarred(next);
try {
const settings = { ...(project.settings || {}), starred: next };
await sw.api.projects.update(project.id, { settings });
} catch (e) { sw.toast(e.message, 'error'); setStarred(!next); }
}
// ── Context menu actions ──
async function handleColor(c) {
try {
await sw.api.projects.update(project.id, { color: c });
sw.toast('Color updated', 'success');
setMenuOpen(false);
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function toggleArchive() {
try {
await sw.api.projects.update(project.id, { is_archived: !project.is_archived });
sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success');
setMenuOpen(false);
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteProject() {
setMenuOpen(false);
if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return;
try {
await sw.api.projects.del(project.id);
sw.toast('Project deleted', 'success');
onUpdate(true);
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Picker helpers ──
async function openPicker(type) {
if (pickerOpen === type) { setPickerOpen(''); return; }
setPickerOpen(type);
setSections(prev => ({ ...prev, [type]: true }));
setPickerLoading(true);
setPickerItems([]);
try {
let items = [];
if (type === 'kbs') {
const r = await sw.api.knowledge.list();
const all = r?.data || r || [];
const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id));
items = all.filter(k => !attached.has(k.id));
} else if (type === 'notes') {
const r = await sw.api.notes.list();
const all = r?.data || r || [];
const attached = new Set(assoc.notes.map(n => n.id || n.note_id));
items = all.filter(n => !attached.has(n.id));
}
setPickerItems(items);
} catch (e) { sw.toast(e.message, 'error'); }
setPickerLoading(false);
}
async function pickItem(type, item) {
try {
if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false);
else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id);
sw.toast('Added', 'success');
setPickerOpen('');
loadAssoc();
onUpdate();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Remove handlers ──
async function removeKb(kbId) {
try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
catch (e) { sw.toast(e.message, 'error'); }
}
async function removeNote(noteId) {
try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
catch (e) { sw.toast(e.message, 'error'); }
}
async function removeChannel(chId) {
try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
catch (e) { sw.toast(e.message, 'error'); }
}
// ── File upload ──
async function uploadFiles(files) {
for (const file of files) {
try {
await sw.api.projects.uploadFile(project.id, file);
sw.toast(`Uploaded ${file.name}`, 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
loadAssoc();
}
function handleUploadClick() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = () => { if (input.files.length) uploadFiles(input.files); };
input.click();
}
function handleDrop(e) {
e.preventDefault();
setDragActive(false);
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
}
// ── New conversation ──
async function newConversation() {
try {
const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' });
const chId = ch?.id || ch?.data?.id;
if (chId) {
await sw.api.projects.addChannel(project.id, chId);
await loadAssoc();
setActiveChannelId(chId);
}
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Render: Picker dropdown ──
function renderPicker(type, emptyHint) {
if (pickerOpen !== type) return null;
return html`<div class="sw-projects__picker">
${pickerLoading && html`<div class="sw-projects__picker-hint">Loading\u2026</div>`}
${!pickerLoading && pickerItems.length === 0 && html`<div class="sw-projects__picker-hint">${emptyHint}</div>`}
${!pickerLoading && pickerItems.map(item => html`
<div key=${item.id} class="sw-projects__picker-item" onClick=${() => pickItem(type, item)}>
${item.title || item.name || item.id}
</div>
`)}
</div>`;
}
// ── Render: Collapsible section ──
function renderSection(key, title, count, content, addAction) {
const open = sections[key];
return html`<div class="sw-projects__section">
<div class="sw-projects__section-header" onClick=${e => { if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}>
<div>
<span class="sw-projects__section-title">${title}</span>
${count != null && html`<span class="sw-projects__section-count">${count}</span>`}
</div>
<div class="sw-projects__section-actions">
${addAction && html`<button class="sw-projects__section-add"
onClick=${e => { e.stopPropagation(); addAction(); }}>+</button>`}
<span class=${'sw-projects__section-chevron' + (open ? ' sw-projects__section-chevron--open' : '')}>\u25B6</span>
</div>
</div>
${open && html`<div class="sw-projects__section-body">${content}</div>`}
</div>`;
}
// ── Render: Left panel ──
function renderLeftPanel() {
if (activeChannelId) {
return html`<div class="sw-projects__chat-wrap">
<div class="sw-projects__chat-back" onClick=${() => setActiveChannelId(null)}>
\u2190 Back to conversations
</div>
<${ChatPane}
channelId=${activeChannelId}
standalone=${false}
handleRef=${chatRef}
className="sw-projects__chat-pane" />
</div>`;
}
return html`<div class="sw-projects__conversations">
<div class="sw-projects__conv-header">
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Conversations</span>
<button class="sw-projects__new-btn" style="font-size:12px;padding:5px 12px;" onClick=${newConversation}>+ New</button>
</div>
<div class="sw-projects__conv-list">
${assoc.channels.length === 0 && html`
<div class="sw-projects__conv-empty">
No conversations yet.<br/>
<button class="sw-projects__new-btn" style="margin-top:12px;font-size:12px;padding:5px 12px;" onClick=${newConversation}>Start a conversation</button>
</div>
`}
${assoc.channels.map(ch => {
const chId = ch.id || ch.channel_id;
return html`<div key=${chId} class="sw-projects__conv-item" onClick=${() => setActiveChannelId(chId)}>
<div class="sw-projects__conv-title">${ch.title || ch.name || chId}</div>
<div class="sw-projects__conv-meta">Last message ${relTime(ch.updated_at)}</div>
</div>`;
})}
</div>
</div>`;
}
// ── Render: Right sidebar ──
function renderSidebar() {
return html`<div class="sw-projects__sidebar">
${renderSection('desc', 'Description', null, html`
<textarea class="sw-projects__section-textarea" rows="3"
value=${desc}
onInput=${e => { setDesc(e.target.value); setDescDirty(true); }}
placeholder="Add a project description\u2026" />
${descDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveDesc}>Save</button>`}
`)}
${renderSection('instructions', 'Instructions', null, html`
<textarea class="sw-projects__section-textarea" rows="4"
value=${instructions}
onInput=${e => { setInstructions(e.target.value); setInstrDirty(true); }}
placeholder="Add instructions to tailor responses\u2026" />
${instrDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveInstructions}>Save</button>`}
`)}
${renderSection('kbs', 'Knowledge Bases', assoc.kbs.length, html`
${assoc.kbs.length === 0 && html`<div class="sw-projects__section-hint">No knowledge bases linked.</div>`}
${assoc.kbs.map(kb => html`
<div key=${kb.id || kb.kb_id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${kb.name || kb.kb_id}</span>
<button class="sw-projects__section-item-remove" onClick=${() => removeKb(kb.id || kb.kb_id)}>Remove</button>
</div>
`)}
${renderPicker('kbs', 'No knowledge bases available.')}
`, () => openPicker('kbs'))}
${renderSection('notes', 'Notes', assoc.notes.length, html`
${assoc.notes.length === 0 && html`<div class="sw-projects__section-hint">No notes linked.</div>`}
${assoc.notes.map(n => html`
<div key=${n.id || n.note_id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${n.title || n.note_id}</span>
<span class="sw-projects__section-item-meta">${fmtDate(n.created_at || n.added_at)}</span>
<button class="sw-projects__section-item-remove" onClick=${() => removeNote(n.id || n.note_id)}>Remove</button>
</div>
`)}
${renderPicker('notes', 'No notes available.')}
`, () => openPicker('notes'))}
${renderSection('files', 'Files', assoc.files.length, html`
${assoc.files.map(f => html`
<div key=${f.id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
</div>
`)}
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
onClick=${handleUploadClick}
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
onDragLeave=${() => setDragActive(false)}
onDrop=${handleDrop}>
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
</div>
`, handleUploadClick)}
</div>`;
}
// ── Main render ──
return html`<div class="sw-projects__detail">
<div class="sw-projects__detail-header">
<${UserMenu} placement="down-right" />
<a class="sw-projects__back" onClick=${onBack}>\u2190 All projects</a>
${editingName
? html`<input class="sw-projects__detail-name-input"
value=${nameVal}
onInput=${e => setNameVal(e.target.value)}
onBlur=${saveName}
onKeyDown=${e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
autoFocus />`
: html`<h2 class="sw-projects__detail-name"
onClick=${() => { setNameVal(project.name || ''); setEditingName(true); }}>
${(project.icon ? project.icon + ' ' : '') + project.name}
</h2>`
}
<div class="sw-projects__header-actions">
<button class=${'sw-projects__icon-btn' + (starred ? ' sw-projects__icon-btn--active' : '')}
onClick=${toggleStar} title="Star project">
${starred ? '\u2605' : '\u2606'}
</button>
<div style="position:relative">
<button class="sw-projects__icon-btn"
onClick=${() => setMenuOpen(!menuOpen)} title="More options">\u22EF</button>
${menuOpen && html`
<div class="sw-projects__ctx-menu">
<button class="sw-projects__ctx-item"
onClick=${() => { setMenuOpen(false); setNameVal(project.name || ''); setEditingName(true); }}>Rename</button>
<div class="sw-projects__color-row">
${COLORS.map(c => html`
<span key=${c}
class=${'sw-projects__color-swatch' + (project.color === c ? ' sw-projects__color-swatch--active' : '')}
style=${'background:' + c}
onClick=${() => handleColor(c)} />
`)}
</div>
<div class="sw-projects__ctx-divider" />
<button class="sw-projects__ctx-item" onClick=${toggleArchive}>
${project.is_archived ? 'Unarchive' : 'Archive'}</button>
<button class="sw-projects__ctx-item sw-projects__ctx-item--danger"
onClick=${deleteProject}>Delete</button>
</div>
`}
</div>
</div>
</div>
<div class="sw-projects__detail-body">
${renderLeftPanel()}
${renderSidebar()}
</div>
</div>`;
}

View File

@@ -0,0 +1,93 @@
/**
* ProjectsSurface — root view-switching component.
*
* No selectedId → card grid list (ProjectsList)
* With selectedId → detail view (ProjectDetail)
*
* Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
*/
const html = window.html;
const { useState, useEffect, useCallback } = window.hooks;
const { render } = window.preact;
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
import { ProjectsList } from './list.js';
import { ProjectDetail } from './detail.js';
function ProjectsSurface() {
const BASE = window.__BASE__ || '';
const deepLinkId = window.__PROJECT_ID__ || '';
const [projects, setProjects] = useState([]);
const [selectedId, setSelectedId] = useState(deepLinkId || '');
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const resp = await sw.api.projects.list();
const list = resp?.data || resp || [];
setProjects(list);
if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
setSelectedId('');
}
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [deepLinkId]);
useEffect(() => { load(); }, [load]);
const selected = projects.find(p => p.id === selectedId) || null;
function selectProject(id) {
setSelectedId(id);
history.replaceState(null, '', BASE + '/projects/' + id);
}
function goBack() {
setSelectedId('');
history.replaceState(null, '', BASE + '/projects');
}
async function createProject(name) {
try {
const proj = await sw.api.projects.create({ name });
sw.toast('Project created', 'success');
await load();
if (proj?.id) selectProject(proj.id);
} catch (e) { sw.toast(e.message, 'error'); }
}
const handleUpdate = useCallback((deleted) => {
if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
load();
}, [load, BASE]);
return html`
<div class="sw-projects">
${selected
? html`<${ProjectDetail}
key=${selectedId}
project=${selected}
onBack=${goBack}
onUpdate=${handleUpdate} />`
: html`<${ProjectsList}
projects=${projects}
loading=${loading}
onSelect=${selectProject}
onCreate=${createProject} />`
}
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────
const mount = document.getElementById('projects-mount');
if (mount) {
render(html`<${ProjectsSurface} />`, mount);
console.log('[projects] Surface mounted');
}
export { ProjectsSurface };

View File

@@ -0,0 +1,124 @@
/**
* ProjectsList — card grid view for all projects.
*
* Props:
* projects — array of project objects
* loading — boolean
* onSelect — (id: string) => void
* onCreate — (name: string) => void
*/
const html = window.html;
const { useState, useMemo } = window.hooks;
import { UserMenu } from '../../shell/user-menu.js';
// ── Relative time helper ────────────────────
const DIVISIONS = [
[60, 'second'], [60, 'minute'], [24, 'hour'],
[30, 'day'], [12, 'month'], [Infinity, 'year'],
];
function relativeTime(dateStr) {
if (!dateStr) return '';
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (val < 10) return 'just now';
for (const [div, unit] of DIVISIONS) {
if (val < div) {
const n = Math.floor(val);
return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
}
val /= div;
}
return '';
}
export function ProjectsList({ projects, loading, onSelect, onCreate }) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState('activity');
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
let list = projects;
if (q) {
list = list.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q)
);
}
if (sort === 'activity') {
list = [...list].sort((a, b) =>
new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
);
} else {
list = [...list].sort((a, b) =>
(a.name || '').localeCompare(b.name || '')
);
}
return list;
}, [projects, search, sort]);
async function handleCreate() {
const name = await sw.prompt('Project name:');
if (name && name.trim()) onCreate(name.trim());
}
if (loading) {
return html`<div class="sw-projects__list">
<div class="sw-projects__empty">Loading projects\u2026</div>
</div>`;
}
return html`
<div class="sw-projects__list">
<div class="sw-projects__list-header">
<${UserMenu} placement="down-right" />
<h1 class="sw-projects__list-title">Projects</h1>
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ New project</button>
</div>
<div class="sw-projects__search-wrap">
<span class="sw-projects__search-icon">\u{1F50D}</span>
<input class="sw-projects__search"
type="text"
placeholder="Search projects\u2026"
value=${search}
onInput=${e => setSearch(e.target.value)} />
</div>
<div class="sw-projects__sort">
<span>Sort by</span>
<select value=${sort} onChange=${e => setSort(e.target.value)}>
<option value="activity">Activity</option>
<option value="name">Name</option>
</select>
</div>
${filtered.length === 0 && !loading && html`
<div class="sw-projects__empty">
${search ? 'No projects match your search.' : 'No projects yet.'}
${!search && html`<div style="margin-top:12px;">
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ Create your first project</button>
</div>`}
</div>
`}
<div class="sw-projects__grid">
${filtered.map(p => html`
<div key=${p.id}
class="sw-projects__card"
style=${p.color ? `border-left: 3px solid ${p.color}` : ''}
onClick=${() => onSelect(p.id)}>
<div class="sw-projects__card-name">
${p.icon ? html`<span>${p.icon}</span>` : ''}
<span>${p.name}</span>
${p.is_archived && html`<span class="sw-projects__card-badge">archived</span>`}
</div>
${p.description && html`
<div class="sw-projects__card-desc">${p.description}</div>
`}
<div class="sw-projects__card-meta">${relativeTime(p.updated_at)}</div>
</div>
`)}
</div>
</div>
`;
}