Changeset 0.37.10 (#222)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 23:39:23 +00:00
committed by xcaliber
parent 37b639c9c8
commit 8d8a118232
52 changed files with 3209 additions and 13811 deletions

View File

@@ -0,0 +1,66 @@
// ==========================================
// Chat Surface — ChatWorkspace Component
// ==========================================
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
// ChatPane runs standalone=false — the surface manages navigation.
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
const html = window.html;
const { useRef, useEffect } = window.hooks;
const PANEL_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">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* onChannelChange: (id: string) => void,
* onNewChat: () => void,
* sidebarOpen: boolean,
* onToggleSidebar: () => void,
* toolsButton?: any,
* }} props
*/
export function ChatWorkspace({
activeId, activeType, onChannelChange, onNewChat,
sidebarOpen, onToggleSidebar, toolsButton,
}) {
const chatRef = useRef(null);
// When activeId changes, tell ChatPane to switch channel
useEffect(() => {
if (chatRef.current) {
chatRef.current.setChannel(activeId);
}
}, [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">
${toolsButton}
</div>
</div>
<${ChatPane}
channelId=${activeId}
standalone=${false}
handleRef=${chatRef}
onChannelChange=${onChannelChange}
className="sw-chat-surface__chat-pane" />
</div>`;
}

View File

@@ -0,0 +1,115 @@
// ==========================================
// Chat Surface — Root Component
// ==========================================
// Main chat surface: sidebar + workspace.
// Follows the settings/admin mount pattern.
//
// v0.37.10: New Preact surface replacing the legacy SPA.
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
import { useWorkspace } from './use-workspace.js';
import { useSidebar } from './use-sidebar.js';
import { Sidebar } from './sidebar.js';
import { ChatWorkspace } from './chat-workspace.js';
import { useTools, ToolsPopup } from './tools-popup.js';
const { render } = window.preact;
const html = window.html;
const { useState, useCallback } = window.hooks;
const WRENCH_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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
7.94-7.94l-3.76 3.76z"/>
</svg>`;
function ChatSurface() {
const workspace = useWorkspace();
const sidebar = useSidebar({ activeId: workspace.activeId });
const tools = useTools();
const [toolsOpen, setToolsOpen] = useState(false);
const _onNewChat = useCallback(() => {
workspace.clearSelection();
}, [workspace.clearSelection]);
const _onChannelChange = useCallback((id) => {
if (id) workspace.selectChat(id);
}, [workspace.selectChat]);
const _toggleSidebar = useCallback(() => {
workspace.setSidebarOpen(!workspace.sidebarOpen);
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
const _toggleTools = useCallback(() => {
setToolsOpen(v => !v);
}, []);
const _closeTools = useCallback(() => {
setToolsOpen(false);
}, []);
// Tools button — shared between mobile bar and workspace header
const toolsButton = html`
<button class="sw-chat-surface__tools-btn"
onClick=${_toggleTools}
title="Tools"
aria-label="Toggle tools"
aria-expanded=${toolsOpen}>
${WRENCH_SVG}
${tools.disabled.size > 0 && html`
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
</button>`;
return html`
<div class="sw-chat-surface">
${/* Mobile sidebar overlay */''}
${workspace.sidebarOpen && html`
<div class="sw-chat-surface__sidebar-overlay"
onClick=${_toggleSidebar} />`}
${/* Sidebar */''}
${workspace.sidebarOpen && html`
<${Sidebar}
sidebar=${sidebar}
activeId=${workspace.activeId}
onSelectChat=${workspace.selectChat}
onSelectChannel=${workspace.selectChannel}
onNewChat=${_onNewChat}
onCollapse=${_toggleSidebar} />`}
${/* Main workspace */''}
<div class="sw-chat-surface__main">
<${ChatWorkspace}
activeId=${workspace.activeId}
activeType=${workspace.activeType}
onChannelChange=${_onChannelChange}
onNewChat=${_onNewChat}
sidebarOpen=${workspace.sidebarOpen}
onToggleSidebar=${_toggleSidebar}
toolsButton=${toolsButton} />
<${ToolsPopup}
open=${toolsOpen}
onClose=${_closeTools}
categories=${tools.categories}
disabled=${tools.disabled}
onToggle=${tools.toggle}
onToggleCategory=${tools.toggleCategory} />
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('chat-mount');
if (mount) {
render(html`<${ChatSurface} />`, mount);
console.log('[chat] Surface mounted (v0.37.10)');
}
export { ChatSurface };

View File

@@ -0,0 +1,65 @@
// ==========================================
// 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

@@ -0,0 +1,199 @@
// ==========================================
// Chat Surface — SidebarChats Component
// ==========================================
// Personal AI chats with folder grouping and context menus.
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>`;
const CHAT_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">
<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 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">
<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"/>
</svg>`;
const DOTS_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">
<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,
onRenameFolder, onDeleteFolder,
}) {
const [contextMenu, setContextMenu] = useState(null);
const menuRef = useRef(null);
// Close context menu on outside click or Escape
useEffect(() => {
if (!contextMenu) return;
function _close(e) {
if (e.type === 'keydown' && e.key !== 'Escape') return;
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
setContextMenu(null);
}
document.addEventListener('click', _close);
document.addEventListener('keydown', _close);
return () => {
document.removeEventListener('click', _close);
document.removeEventListener('keydown', _close);
};
}, [contextMenu]);
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 _action = useCallback(async (action, item) => {
setContextMenu(null);
if (action === 'rename') {
const newTitle = 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 + '?')) {
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:');
if (name && name.trim()) await onCreateFolder(name.trim());
} else if (action.startsWith('move-to:')) {
const fid = action.slice(8);
await onMoveToFolder(item.id, fid);
}
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
// Count total chats
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
const unfolderedChats = chatsByFolder[''] || [];
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">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>`}
</div>`;
}
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
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); }}
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>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
title="Chat actions"
aria-label="Chat actions">
${DOTS_SVG}
</button>
</button>`;
}
function _stopAndOpen(e, onOpenMenu, type, item) {
e.stopPropagation();
onOpenMenu(e, type, item);
}

View File

@@ -0,0 +1,109 @@
// ==========================================
// Chat Surface — Sidebar Component
// ==========================================
// Search bar, new chat, channels section, chats section 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 { UserMenu } from '../../shell/user-menu.js';
const html = window.html;
const { useCallback } = window.hooks;
const 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">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`;
const SEARCH_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">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`;
const COLLAPSE_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">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
* activeId: string|null,
* onSelectChat: (id: string) => void,
* onSelectChannel: (id: string) => void,
* onNewChat: () => void,
* onCollapse: () => void,
* }} props
*/
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
const _onSearch = useCallback((e) => {
sidebar.setSearch(e.target.value);
}, [sidebar.setSearch]);
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>
</button>
<button class="sw-chat-surface__collapse-btn"
onClick=${onCollapse}
title="Collapse sidebar"
aria-label="Collapse sidebar">
${COLLAPSE_SVG}
</button>
</div>
${/* Search */''}
<div class="sw-chat-surface__search-wrap">
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
<input class="sw-chat-surface__search"
type="text"
placeholder="Search\u2026"
value=${sidebar.search}
onInput=${_onSearch}
aria-label="Search chats and channels" />
</div>
${/* 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}
folders=${sidebar.folders}
activeId=${activeId}
collapsed=${sidebar.collapsed.chats}
onToggle=${() => sidebar.toggleCollapsed('chats')}
onSelect=${onSelectChat}
onRename=${sidebar.renameChat}
onDelete=${sidebar.deleteChat}
onMoveToFolder=${sidebar.moveToFolder}
onCreateFolder=${sidebar.createFolder}
onRenameFolder=${sidebar.renameFolder}
onDeleteFolder=${sidebar.deleteFolder} />
</div>
${/* Footer: UserMenu only */''}
<div class="sw-chat-surface__sidebar-footer">
<${UserMenu} placement="up-right" />
</div>
</div>`;
}

View File

@@ -0,0 +1,171 @@
// ==========================================
// Chat Surface — ToolsPopup Component
// ==========================================
// Scrollable popup anchored to a toolbar icon.
// Category-based tool toggles with persistence to localStorage.
const html = window.html;
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
const STORAGE_KEY = 'sw-disabled-tools';
/**
* Load tools list from API and organize by category.
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
*/
export function useTools() {
const [tools, setTools] = useState([]);
const [disabled, setDisabled] = useState(_loadDisabled);
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 || []);
}).catch(() => {});
}
}, []);
const categories = useMemo(() => {
const map = {};
for (const t of tools) {
const cat = t.category || 'Other';
if (!map[cat]) map[cat] = [];
map[cat].push(t);
}
return Object.entries(map).map(([name, items]) => ({ name, items }));
}, [tools]);
const toggle = useCallback((toolName) => {
setDisabled(prev => {
const next = new Set(prev);
if (next.has(toolName)) next.delete(toolName);
else next.add(toolName);
_saveDisabled(next);
return next;
});
}, []);
const toggleCategory = useCallback((catName) => {
setDisabled(prev => {
const cat = categories.find(c => c.name === catName);
if (!cat) return prev;
const next = new Set(prev);
const allDisabled = cat.items.every(t => next.has(t.name));
for (const t of cat.items) {
if (allDisabled) next.delete(t.name);
else next.add(t.name);
}
_saveDisabled(next);
return next;
});
}, [categories]);
const getDisabled = useCallback(() => [...disabled], [disabled]);
return { categories, disabled, toggle, toggleCategory, getDisabled };
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
* disabled: Set<string>,
* onToggle: (name: string) => void,
* onToggleCategory: (catName: string) => void,
* }} props
*/
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
const panelRef = useRef(null);
const [expandedCats, setExpandedCats] = useState({});
// Close on Escape or outside click
useEffect(() => {
if (!open) return;
function _onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
}
function _onClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
}
document.addEventListener('keydown', _onKey, true);
setTimeout(() => document.addEventListener('click', _onClick), 0);
return () => {
document.removeEventListener('keydown', _onKey, true);
document.removeEventListener('click', _onClick);
};
}, [open, onClose]);
const _toggleCat = useCallback((name) => {
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
}, []);
if (!open) return null;
const disabledCount = disabled.size;
return html`
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
<div class="sw-tools-popup__header">
<span class="sw-tools-popup__title">Tools</span>
${disabledCount > 0 && html`
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
</div>
<div class="sw-tools-popup__body">
${categories.length === 0 && html`
<div class="sw-tools-popup__empty">No tools available</div>`}
${categories.map(cat => {
const allDisabled = cat.items.every(t => disabled.has(t.name));
const someDisabled = cat.items.some(t => disabled.has(t.name));
const isExpanded = expandedCats[cat.name] !== false; // default open
return html`
<div key=${cat.name} class="sw-tools-popup__category">
<div class="sw-tools-popup__cat-header">
<button class="sw-tools-popup__cat-toggle"
onClick=${() => _toggleCat(cat.name)}
aria-expanded=${isExpanded}>
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
\u25B6
</span>
<span>${cat.name}</span>
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
</button>
<label class="sw-tools-popup__cat-switch">
<input type="checkbox"
checked=${!allDisabled}
indeterminate=${someDisabled && !allDisabled}
onChange=${() => onToggleCategory(cat.name)} />
</label>
</div>
${isExpanded && cat.items.map(tool => html`
<label key=${tool.name} class="sw-tools-popup__tool">
<input type="checkbox"
checked=${!disabled.has(tool.name)}
onChange=${() => onToggle(tool.name)} />
<span class="sw-tools-popup__tool-name">${tool.name}</span>
${tool.description && html`
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
</label>`)}
</div>`;
})}
</div>
</div>`;
}
function _loadDisabled() {
try {
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
return new Set(arr || []);
} catch (_) { return new Set(); }
}
function _saveDisabled(set) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
}

View File

@@ -0,0 +1,175 @@
// ==========================================
// Chat Surface — useSidebar Hook
// ==========================================
// Loads and manages sidebar data: channels (DMs + named),
// personal chats, and folders. Subscribes to WS events
// for live updates.
const { useState, useEffect, useCallback, useMemo } = window.hooks;
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
/**
* @param {{ activeId: string|null }} opts
*/
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 [folders, setFolders] = useState([]);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(_loadCollapsed);
const [loading, setLoading] = useState(true);
// ── Load data ───────────────────────────
const reload = useCallback(async () => {
if (!window.sw?.api?.channels?.list) return;
try {
const foldersPromise = window.sw.api.folders?.list
? 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
foldersPromise,
]);
setChannels(_extract(chResp));
setChats(_extract(dmResp));
setFolders(_extract(fResp));
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => { reload(); }, [reload]);
// ── WebSocket live updates ──────────────
useEffect(() => {
if (!window.sw?.on) return;
const handlers = {
'channel.created': reload,
'channel.updated': reload,
'channel.deleted': reload,
'folder.created': reload,
'folder.updated': reload,
'folder.deleted': reload,
};
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
return () => {
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
};
}, [reload]);
// ── Search filter ───────────────────────
const filteredChannels = useMemo(() => {
if (!search) return channels;
const q = search.toLowerCase();
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
}, [channels, 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(() => {
const map = { '': [] };
for (const f of (folders || [])) map[f.id] = [];
for (const c of filteredChats) {
const fid = c.folder_id || '';
if (!map[fid]) map[fid] = [];
map[fid].push(c);
}
return map;
}, [filteredChats, folders]);
// ── Collapse sections ───────────────────
const toggleCollapsed = useCallback((key) => {
setCollapsed(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
return next;
});
}, []);
// ── CRUD helpers ────────────────────────
const createFolder = useCallback(async (name) => {
if (!window.sw?.api?.folders?.create) return;
await window.sw.api.folders.create(name);
reload();
}, [reload]);
const renameChat = useCallback(async (id, title) => {
await window.sw.api.channels.update(id, { title });
reload();
}, [reload]);
const deleteChat = useCallback(async (id) => {
await window.sw.api.channels.del(id);
reload();
}, [reload]);
const moveToFolder = useCallback(async (chatId, folderId) => {
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
reload();
}, [reload]);
const renameFolder = useCallback(async (id, name) => {
await window.sw.api.folders.update(id, { name });
reload();
}, [reload]);
const deleteFolder = useCallback(async (id) => {
await window.sw.api.folders.del(id);
reload();
}, [reload]);
return {
channels: filteredChannels,
chats: filteredChats,
chatsByFolder,
folders,
search,
setSearch,
collapsed,
toggleCollapsed,
loading,
reload,
// CRUD
createFolder,
renameChat,
deleteChat,
moveToFolder,
renameFolder,
deleteFolder,
};
}
function _extract(resp) {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (Array.isArray(resp.data)) return resp.data;
// Handle wrapped responses like { folders: [], channels: [] }
const vals = Object.values(resp);
for (const v of vals) {
if (Array.isArray(v)) return v;
}
return [];
}
function _loadCollapsed() {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
} catch (_) { return {}; }
}

View File

@@ -0,0 +1,78 @@
// ==========================================
// Chat Surface — useWorkspace Hook
// ==========================================
// Coordinator hook: manages active conversation, sidebar state.
// Persists active selection to sessionStorage for reload restore.
const { useState, useCallback, useEffect } = window.hooks;
const STORAGE_KEY = 'sw-chat-active';
/**
* @returns {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* sidebarOpen: boolean,
* setSidebarOpen: (v: boolean) => void,
* selectChat: (id: string) => void,
* selectChannel: (id: string) => void,
* clearSelection: () => void,
* }}
*/
export function useWorkspace() {
// Restore from sessionStorage
const stored = _loadStored();
const [activeId, setActiveId] = useState(stored.id);
const [activeType, setActiveType] = useState(stored.type);
const [sidebarOpen, setSidebarOpen] = useState(true);
// Persist on change
useEffect(() => {
if (activeId) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
} catch (_) {}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}, [activeId, activeType]);
const selectChat = useCallback((id) => {
setActiveId(id);
setActiveType('chat');
// 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);
}, []);
const clearSelection = useCallback(() => {
setActiveId(null);
setActiveType(null);
}, []);
return {
activeId,
activeType,
sidebarOpen,
setSidebarOpen,
selectChat,
selectChannel,
clearSelection,
};
}
function _loadStored() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const obj = JSON.parse(raw);
return { id: obj.id || null, type: obj.type || null };
}
} catch (_) {}
return { id: null, type: null };
}