Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
104
src/js/sw/surfaces/chat/channel-members-panel.js
Normal file
104
src/js/sw/surfaces/chat/channel-members-panel.js
Normal 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>`}
|
||||
<//>`;
|
||||
}
|
||||
108
src/js/sw/surfaces/chat/channel-settings-panel.js
Normal file
108
src/js/sw/surfaces/chat/channel-settings-panel.js
Normal 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>`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user