diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dffa0b..49e5e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [0.4.105] - 2026-03-18 + +### Fixed +- **DM search stability** — The DM workspace now uses an explicit `isDmSearchActive()` helper that consistently suspends all live-refresh paths (event polling, snapshot resync, visibility-change refresh, manual Refresh button) while a search query is active. The Refresh button reloads the current search page instead of silently reverting to the live thread. +- **Channel search stability** — Background thread refresh no longer overwrites channel search results. A new `rerunActiveChannelSearch()` path keeps search results coherent after local actions (delete, edit, stream create, note publish/rate, skill endorse) without reverting to the live thread. Search results scroll to the newest matches on initial search; reruns after local actions preserve scroll position. + +### Improved +- **Left-rail card labels** — Card mode labels are hidden when collapsed (the chevron already indicates the state) and tightened to prevent overlap with count badges on narrow sidebars. + +## [0.4.103] - 2026-03-18 + +### Improved +- **Bell seen vs clear separation** — Opening the bell now clears the red badge without removing entries from the dropdown. A new `seenThrough` localStorage watermark tracks which items the user has already glanced at, while the existing `dismissedThrough` cursor still controls list removal via the Clear button. Badge count reflects only items newer than the seen cursor. Both cursors stay coherent (Clear advances both). + +## [0.4.102] - 2026-03-18 + +### Improved +- **Left-rail card states** — Recent DMs and Connected cards now support three persistent viewing states: collapsed, top 5 (peek), and expanded (bounded scroll). State persists per user in localStorage. Header toggle collapses/expands; footer toggle switches between peek and full list. DM unread total now reflects all contacts, not just the visible slice. +- **Mini-player placement** — The sidebar mini-player can now be moved between a top and bottom slot. Placement persists per user in localStorage. Defaults to the top utility slot. + +## [0.4.101] - 2026-03-18 + +### Fixed +- **Channel read clears attention immediately** — Opening a channel now triggers an immediate sidebar and bell attention refresh when unread state is cleared, instead of waiting for the next poll cycle. `mark_channel_read()` returns whether it actually cleared unread state, the AJAX response exposes `marked_read`, and the channel view calls `requestCanopySidebarAttentionRefresh({ force: true })` on a positive transition. + ## [0.4.100] - 2026-03-17 ### Added diff --git a/README.md b/README.md index b48a46f..dc8f4ac 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
-
+
@@ -79,13 +79,13 @@ Most chat products treat AI as bolt-on automation hanging off webhooks or extern
Recent user-facing changes reflected in the app and docs:
+- **Search stability and UX** in `0.4.104`-`0.4.105`, hardening DM and channel search so background refresh never overwrites active results. Channel search scrolls to the newest matches. Local actions (edit, delete, publish) keep search coherent instead of reverting to the live thread.
+- **Sidebar polish** in `0.4.101`-`0.4.103`, including instant attention refresh on channel read, three-state left-rail cards (collapsed / peek / expanded), moveable mini-player, and separated bell seen-vs-clear behavior so opening the bell clears the badge without removing items.
- **First-run guidance and smart landing** in `0.4.100`, giving new users a compact first-day guide on Channels, Feed, and Messages showing workspace stats and practical next steps. Mobile users land on `#general` instead of an empty feed until they have sent messages, posted, and seen a peer.
- **Event-driven attention center** in `0.4.97`-`0.4.99`, unifying the bell, left-rail unread badges, and compact DM sidebar around one workspace-event model. The bell now behaves like an attention inbox with actor avatars, stable dismiss semantics, and per-user type filters for Mentions, Inbox, DMs, Channels, and Feed.
- **Curated channels with durable enforcement** in `0.4.91`-`0.4.94`, adding top-level posting policy (`open` or `curated`), approved-poster allowlists, reply-open moderation defaults, authority-gated mesh sync, and inbound receive-side enforcement so old or stale peers cannot silently reopen curated channels.
-- **Responsive channel workspace polish** in `0.4.95`-`0.4.96`, including channel-header compaction for narrow and landscape layouts plus YouTube click-to-play facades that avoid immediate iframe flood and third-party throttling.
- **Inline map, chart, and rich media embeds** in `0.4.84`-`0.4.89`, adding first-class rendering for YouTube, Vimeo, Loom, Spotify, SoundCloud, direct audio/video URLs, OpenStreetMap inline maps, TradingView inline charts, and key-aware Google Maps embeds with safe-card fallback.
- **Streaming runtime hardening** in `0.4.84`-`0.4.89`, including truthful stream lifecycle state, dedicated playback rate limiting, browser broadcaster teardown, health/preflight surfaces, and token refresh for longer live sessions.
-- **Agent event-feed maturity** in `0.4.77`-`0.4.80`, adding `GET /api/v1/agents/me/events`, durable event subscriptions, quiet-feed support, and cleaner heartbeat/admin diagnostics for real agent runtimes.
See [CHANGELOG.md](CHANGELOG.md) for release history.
diff --git a/canopy/__init__.py b/canopy/__init__.py
index 7a4f618..9b82228 100644
--- a/canopy/__init__.py
+++ b/canopy/__init__.py
@@ -11,7 +11,7 @@
Development: AI-assisted implementation (Claude, Codex, GitHub Copilot, Cursor IDE, Ollama)
"""
-__version__ = "0.4.100"
+__version__ = "0.4.105"
__protocol_version__ = 1
__author__ = "Canopy Contributors"
__license__ = "Apache-2.0"
diff --git a/canopy/core/channels.py b/canopy/core/channels.py
index c7ec515..4e70f61 100644
--- a/canopy/core/channels.py
+++ b/canopy/core/channels.py
@@ -4802,7 +4802,7 @@ def get_member_role(self, channel_id: str, user_id: str) -> Optional[str]:
role = decision.get('role')
return str(role) if role else None
- def mark_channel_read(self, channel_id: str, user_id: str) -> None:
+ def mark_channel_read(self, channel_id: str, user_id: str) -> bool:
"""Update last_read_at for a user in a channel to now, clearing its unread count."""
try:
with self.db.get_connection() as conn:
@@ -4824,7 +4824,7 @@ def mark_channel_read(self, channel_id: str, user_id: str) -> None:
(channel_id, user_id),
).fetchone()
if not unread_exists:
- return
+ return False
conn.execute(
"""UPDATE channel_members SET last_read_at = CURRENT_TIMESTAMP
WHERE channel_id = ? AND user_id = ?""",
@@ -4839,8 +4839,10 @@ def mark_channel_read(self, channel_id: str, user_id: str) -> None:
payload={"reason": "channel_read"},
dedupe_suffix=f"channel_read:{user_id}",
)
+ return True
except Exception as e:
logger.warning(f"Failed to mark channel {channel_id} as read for {user_id}: {e}")
+ return False
def is_channel_admin(self, channel_id: str, user_id: str) -> bool:
"""Check if a user is an admin (or creator) of a channel."""
diff --git a/canopy/ui/routes.py b/canopy/ui/routes.py
index 0a6940c..8e4136a 100644
--- a/canopy/ui/routes.py
+++ b/canopy/ui/routes.py
@@ -10511,8 +10511,8 @@ def ajax_get_channel_messages(channel_id):
'reason': access.get('reason'),
}), 403
return jsonify({'error': 'You are not a member of this channel'}), 403
- # Mark channel as read now that the user is viewing it
- channel_manager.mark_channel_read(channel_id, user_id)
+ # Mark channel as read now that the user is viewing it.
+ marked_read = channel_manager.mark_channel_read(channel_id, user_id) is True
try:
workspace_event_cursor = int((workspace_event_manager.get_latest_seq() if workspace_event_manager else 0) or 0)
except Exception:
@@ -11139,6 +11139,7 @@ def _user_display(uid: str) -> Optional[dict[str, Any]]:
'messages': messages_data,
'channel_id': channel_id,
'count': len(messages_data),
+ 'marked_read': marked_read,
'workspace_event_cursor': workspace_event_cursor,
'focus_message_id': focus_message_id or None,
'focus_message_found': focus_message_found,
diff --git a/canopy/ui/static/js/canopy-main.js b/canopy/ui/static/js/canopy-main.js
index 59290d4..06272fe 100644
--- a/canopy/ui/static/js/canopy-main.js
+++ b/canopy/ui/static/js/canopy-main.js
@@ -349,11 +349,137 @@
const canopyInitialAttentionActivityRev = window.CANOPY_VARS ? (window.CANOPY_VARS.attentionActivityRev || '') : '';
const canopyInitialAttentionEventCursor = window.CANOPY_VARS ? Number(window.CANOPY_VARS.attentionEventCursor || 0) : 0;
const canopyLocalPeerId = window.CANOPY_VARS ? String(window.CANOPY_VARS.localPeerId || '').trim() : '';
- const SIDEBAR_VISIBLE_PEER_LIMIT = 12;
+ const SIDEBAR_CARD_PEEK_LIMIT = 5;
+ const canopySidebarRailStoragePrefix = (() => {
+ const userId = window.CANOPY_VARS ? String(window.CANOPY_VARS.userId || 'local_user').trim() : 'local_user';
+ return `canopy.sidebar.rail.${userId || 'local_user'}`;
+ })();
window.canopyPeerProfiles = canopyPeerProfiles || {};
window.canopyPeerTrust = canopyPeerTrust || {};
window.canopyInitialConnectedPeers = canopyInitialConnectedPeers || [];
+ function normalizeSidebarCardState(state) {
+ const raw = String(state || '').trim().toLowerCase();
+ if (raw === 'collapsed' || raw === 'expanded') return raw;
+ return 'peek';
+ }
+
+ function loadSidebarRailPreference(key, fallback) {
+ try {
+ if (!window.localStorage) return fallback;
+ const raw = window.localStorage.getItem(`${canopySidebarRailStoragePrefix}.${key}`);
+ return raw == null ? fallback : raw;
+ } catch (_) {
+ return fallback;
+ }
+ }
+
+ function saveSidebarRailPreference(key, value) {
+ try {
+ if (window.localStorage) {
+ window.localStorage.setItem(`${canopySidebarRailStoragePrefix}.${key}`, String(value));
+ }
+ } catch (_) {}
+ }
+
+ const canopySidebarRailState = {
+ cards: {
+ dm: normalizeSidebarCardState(loadSidebarRailPreference('dmCardState', 'peek')),
+ peers: normalizeSidebarCardState(loadSidebarRailPreference('peerCardState', 'peek')),
+ },
+ miniPosition: String(loadSidebarRailPreference('miniPosition', 'top') || 'top').trim().toLowerCase() === 'bottom' ? 'bottom' : 'top',
+ };
+
+ function getSidebarCardState(kind) {
+ return canopySidebarRailState.cards[kind] || 'peek';
+ }
+
+ function setSidebarCardState(kind, nextState) {
+ const normalized = normalizeSidebarCardState(nextState);
+ canopySidebarRailState.cards[kind] = normalized;
+ saveSidebarRailPreference(kind === 'dm' ? 'dmCardState' : 'peerCardState', normalized);
+ if (kind === 'dm') {
+ canopyRenderSidebarDmContacts(canopySidebarDmState.contacts);
+ } else if (kind === 'peers') {
+ renderSidebarPeers();
+ }
+ }
+
+ function toggleSidebarCardCollapsed(kind) {
+ const current = getSidebarCardState(kind);
+ setSidebarCardState(kind, current === 'collapsed' ? 'peek' : 'collapsed');
+ }
+
+ function toggleSidebarCardExpansion(kind, totalCount) {
+ const current = getSidebarCardState(kind);
+ const normalizedTotal = Math.max(0, Number(totalCount) || 0);
+ if (normalizedTotal <= SIDEBAR_CARD_PEEK_LIMIT) {
+ if (current === 'collapsed') {
+ setSidebarCardState(kind, 'peek');
+ }
+ return;
+ }
+ setSidebarCardState(kind, current === 'expanded' ? 'peek' : 'expanded');
+ }
+
+ function visibleSidebarCardItems(kind, items) {
+ const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
+ const state = getSidebarCardState(kind);
+ if (state === 'collapsed') return [];
+ if (state === 'expanded') return normalized;
+ return normalized.slice(0, SIDEBAR_CARD_PEEK_LIMIT);
+ }
+
+ function updateSidebarCardChrome(kind, totalCount) {
+ const state = getSidebarCardState(kind);
+ const safeTotal = Math.max(0, Number(totalCount) || 0);
+ const prefix = kind === 'dm' ? 'sidebar-dm' : 'sidebar-peers';
+ const card = document.getElementById(`${prefix}-card`);
+ const modeLabel = document.getElementById(`${prefix}-mode-label`);
+ const toggleBtn = document.getElementById(`${prefix}-toggle`);
+ const footer = document.getElementById(`${prefix}-footer`);
+ const summary = document.getElementById(`${prefix}-summary`);
+ const expandBtn = document.getElementById(`${prefix}-expand-btn`);
+ const hasOverflow = safeTotal > SIDEBAR_CARD_PEEK_LIMIT;
+ if (card) {
+ card.setAttribute('data-view-state', state);
+ }
+ if (modeLabel) {
+ modeLabel.textContent = state === 'collapsed' ? '' : (state === 'expanded' ? 'All' : 'Top 5');
+ modeLabel.hidden = state === 'collapsed';
+ }
+ if (toggleBtn) {
+ const icon = toggleBtn.querySelector('i');
+ if (icon) {
+ icon.className = `bi bi-chevron-${state === 'collapsed' ? 'down' : 'up'}`;
+ }
+ toggleBtn.setAttribute('aria-label', `${state === 'collapsed' ? 'Expand' : 'Collapse'} ${kind === 'dm' ? 'recent direct messages' : 'connected peers'}`);
+ }
+ if (summary) {
+ if (safeTotal <= 0) {
+ summary.textContent = kind === 'dm' ? 'No recent conversations' : 'No active peers';
+ } else if (state === 'expanded' && hasOverflow) {
+ summary.textContent = `Showing all ${safeTotal}`;
+ } else if (hasOverflow) {
+ summary.textContent = `Showing top ${SIDEBAR_CARD_PEEK_LIMIT} of ${safeTotal}`;
+ } else {
+ summary.textContent = `Showing all ${safeTotal}`;
+ }
+ }
+ if (footer) {
+ footer.hidden = state === 'collapsed' || safeTotal <= 0;
+ }
+ if (expandBtn) {
+ expandBtn.hidden = state === 'collapsed' || !hasOverflow;
+ expandBtn.innerHTML = state === 'expanded'
+ ? 'Show less'
+ : `View ${safeTotal - SIDEBAR_CARD_PEEK_LIMIT} more`;
+ expandBtn.setAttribute('aria-label', state === 'expanded'
+ ? `Show fewer ${kind === 'dm' ? 'direct messages' : 'connected peers'}`
+ : `Show more ${kind === 'dm' ? 'direct messages' : 'connected peers'}`);
+ }
+ }
+
function canopyInitial(label) {
const text = (label || '?').trim();
return text ? text[0].toUpperCase() : '?';
@@ -523,8 +649,6 @@
function renderSidebarPeers() {
const listEl = document.getElementById('sidebar-peer-list');
- const moreWrap = document.getElementById('sidebar-peer-more');
- const moreBtn = document.getElementById('sidebar-peer-more-btn');
if (!listEl) return;
const activePeers = Array.from(canopySidebarPeerState.peers.values())
.filter(record => record && record.active)
@@ -541,24 +665,16 @@
empty.textContent = 'No active peers';
listEl.appendChild(empty);
setSidebarPeerCount(0);
- if (moreWrap) moreWrap.style.display = 'none';
+ updateSidebarCardChrome('peers', 0);
return;
}
- const visiblePeers = activePeers.slice(0, SIDEBAR_VISIBLE_PEER_LIMIT);
+ const visiblePeers = visibleSidebarCardItems('peers', activePeers);
visiblePeers.forEach(record => {
listEl.appendChild(createSidebarPeerElement(record));
});
setSidebarPeerCount(activePeers.length);
- const overflowCount = Math.max(0, activePeers.length - visiblePeers.length);
- if (moreWrap && moreBtn) {
- if (overflowCount > 0) {
- moreWrap.style.display = '';
- moreBtn.textContent = `View ${overflowCount} more peer${overflowCount === 1 ? '' : 's'}`;
- } else {
- moreWrap.style.display = 'none';
- }
- }
+ updateSidebarCardChrome('peers', activePeers.length);
renderSidebarPeerModalList();
}
@@ -635,10 +751,27 @@
document.addEventListener('DOMContentLoaded', function() {
seedSidebarPeerState();
+ canopyRenderSidebarDmContacts(canopySidebarDmState.contacts);
renderSidebarPeers();
- const moreBtn = document.getElementById('sidebar-peer-more-btn');
- if (moreBtn) {
- moreBtn.addEventListener('click', openSidebarPeersModal);
+ const dmToggleBtn = document.getElementById('sidebar-dm-toggle');
+ if (dmToggleBtn) {
+ dmToggleBtn.addEventListener('click', () => toggleSidebarCardCollapsed('dm'));
+ }
+ const dmExpandBtn = document.getElementById('sidebar-dm-expand-btn');
+ if (dmExpandBtn) {
+ dmExpandBtn.addEventListener('click', () => toggleSidebarCardExpansion('dm', canopySidebarDmState.contacts.length));
+ }
+ const peersToggleBtn = document.getElementById('sidebar-peers-toggle');
+ if (peersToggleBtn) {
+ peersToggleBtn.addEventListener('click', () => toggleSidebarCardCollapsed('peers'));
+ }
+ const peersExpandBtn = document.getElementById('sidebar-peers-expand-btn');
+ if (peersExpandBtn) {
+ peersExpandBtn.addEventListener('click', () => toggleSidebarCardExpansion('peers', getActiveSidebarPeers().length));
+ }
+ const peersOpenModalBtn = document.getElementById('sidebar-peers-open-modal');
+ if (peersOpenModalBtn) {
+ peersOpenModalBtn.addEventListener('click', openSidebarPeersModal);
}
const peerSearch = document.getElementById('sidebar-peers-search');
if (peerSearch) {
@@ -724,7 +857,8 @@
const totalEl = document.getElementById('sidebar-dm-unread-total');
if (!listEl) return;
- const normalized = Array.isArray(contacts) ? contacts.filter(Boolean).slice(0, 5) : [];
+ const normalized = Array.isArray(contacts) ? contacts.filter(Boolean) : [];
+ const visibleContacts = visibleSidebarCardItems('dm', normalized);
const totalUnread = normalized.reduce((sum, contact) => sum + Math.max(0, Number(contact && contact.unread_count) || 0), 0);
if (totalEl) totalEl.textContent = String(totalUnread);
@@ -734,10 +868,11 @@
empty.className = 'sidebar-peer-empty';
empty.textContent = 'No recent direct messages';
listEl.appendChild(empty);
+ updateSidebarCardChrome('dm', 0);
return;
}
- normalized.forEach(contact => {
+ visibleContacts.forEach(contact => {
const link = document.createElement('a');
link.className = 'sidebar-dm-contact';
if (Number(contact.unread_count) > 0) {
@@ -807,6 +942,8 @@
listEl.appendChild(link);
});
+
+ updateSidebarCardChrome('dm', normalized.length);
}
window.syncCanopySidebarDmContacts = function(payload) {
@@ -928,6 +1065,10 @@
const userId = window.CANOPY_VARS ? String(window.CANOPY_VARS.userId || 'local_user').trim() : 'local_user';
return `canopy.attention.dismissedThrough.${userId || 'local_user'}`;
})();
+ const canopyAttentionSeenStorageKey = (() => {
+ const userId = window.CANOPY_VARS ? String(window.CANOPY_VARS.userId || 'local_user').trim() : 'local_user';
+ return `canopy.attention.seenThrough.${userId || 'local_user'}`;
+ })();
const CANOPY_ATTENTION_FILTER_DEFS = [
{ key: 'mention', label: 'Mentions', icon: 'bi-at' },
@@ -997,6 +1138,26 @@
return normalized;
}
+ function loadCanopyAttentionSeenCursor() {
+ try {
+ const raw = window.localStorage ? window.localStorage.getItem(canopyAttentionSeenStorageKey) : null;
+ return Math.max(0, Number(raw || 0) || 0);
+ } catch (_) {
+ return 0;
+ }
+ }
+
+ function saveCanopyAttentionSeenCursor(value) {
+ const normalized = Math.max(0, Number(value || 0) || 0);
+ canopySidebarAttentionState.seenThroughCursor = normalized;
+ try {
+ if (window.localStorage) {
+ window.localStorage.setItem(canopyAttentionSeenStorageKey, String(normalized));
+ }
+ } catch (_) {}
+ return normalized;
+ }
+
function filterCanopyAttentionItems(items) {
const dismissedThrough = Math.max(0, Number(canopySidebarAttentionState.dismissedThroughCursor || 0) || 0);
const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
@@ -1012,7 +1173,24 @@
});
}
+ function countUnseenCanopyAttentionItems(items) {
+ const seenThrough = Math.max(
+ 0,
+ Number(canopySidebarAttentionState.seenThroughCursor || 0) || 0,
+ Number(canopySidebarAttentionState.dismissedThroughCursor || 0) || 0
+ );
+ const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
+ return normalized.reduce((sum, item) => {
+ const seq = Math.max(0, Number(item && item.seq || 0) || 0);
+ return sum + (seq > seenThrough ? 1 : 0);
+ }, 0);
+ }
+
canopySidebarAttentionState.dismissedThroughCursor = loadCanopyAttentionDismissCursor();
+ canopySidebarAttentionState.seenThroughCursor = Math.max(
+ canopySidebarAttentionState.dismissedThroughCursor,
+ loadCanopyAttentionSeenCursor()
+ );
canopySidebarAttentionState.filters = loadCanopyAttentionFilters();
const SIDEBAR_ATTENTION_EVENT_TYPES = [
@@ -4523,11 +4701,11 @@
window.renderCanopyAttentionBell = function(items) {
const normalized = Array.isArray(items) ? items.filter(Boolean).slice(0, 12) : [];
if (!listEl) {
- setBadge(normalized.length);
+ setBadge(countUnseenCanopyAttentionItems(normalized));
return;
}
listEl.innerHTML = '';
- setBadge(normalized.length);
+ setBadge(countUnseenCanopyAttentionItems(normalized));
if (!normalized.length) {
if (emptyWrap) emptyWrap.style.display = 'block';
return;
@@ -4610,6 +4788,7 @@
clearBtn.addEventListener('click', () => {
canopySidebarAttentionState.items = [];
saveCanopyAttentionDismissCursor(canopySidebarAttentionState.currentEventCursor);
+ saveCanopyAttentionSeenCursor(canopySidebarAttentionState.currentEventCursor);
if (window.renderCanopyAttentionBell) {
window.renderCanopyAttentionBell([]);
}
@@ -4628,6 +4807,7 @@
if (bellBtn) {
bellBtn.addEventListener('click', () => {
+ saveCanopyAttentionSeenCursor(canopySidebarAttentionState.currentEventCursor);
if (window.renderCanopyAttentionBell) {
window.renderCanopyAttentionBell(filterCanopyAttentionItems(canopySidebarAttentionState.items));
}
@@ -4676,10 +4856,13 @@
const playBtn = document.getElementById('sidebar-media-mini-play');
const jumpBtn = document.getElementById('sidebar-media-mini-jump');
const pipBtn = document.getElementById('sidebar-media-mini-pip');
+ const pinBtn = document.getElementById('sidebar-media-mini-pin');
const closeBtn = document.getElementById('sidebar-media-mini-close');
const timeEl = document.getElementById('sidebar-media-mini-time');
const mainScroller = document.querySelector('.main-content');
const miniVideoHost = document.getElementById('sidebar-media-mini-video');
+ const topSlot = document.getElementById('sidebar-media-mini-slot-top');
+ const bottomSlot = document.getElementById('sidebar-media-mini-slot-bottom');
const state = {
current: null,
@@ -4692,6 +4875,29 @@
dockedSubtitle: null
};
+ function updateMiniPlacementControl() {
+ if (!pinBtn) return;
+ const atBottom = canopySidebarRailState.miniPosition === 'bottom';
+ pinBtn.innerHTML = atBottom
+ ? ''
+ : '';
+ pinBtn.title = atBottom ? 'Move mini player to top' : 'Move mini player lower';
+ pinBtn.setAttribute('aria-label', atBottom ? 'Move mini player to top' : 'Move mini player lower');
+ }
+
+ function setCanopySidebarMiniPosition(nextPosition) {
+ const normalized = String(nextPosition || '').trim().toLowerCase() === 'bottom' ? 'bottom' : 'top';
+ const targetSlot = normalized === 'bottom' ? bottomSlot : topSlot;
+ if (mini && targetSlot && mini.parentElement !== targetSlot) {
+ targetSlot.appendChild(mini);
+ }
+ canopySidebarRailState.miniPosition = normalized;
+ saveSidebarRailPreference('miniPosition', normalized);
+ updateMiniPlacementControl();
+ }
+
+ setCanopySidebarMiniPosition(canopySidebarRailState.miniPosition);
+
function mediaTypeFor(el) {
if (!el || !el.tagName) return '';
const tag = el.tagName.toLowerCase();
@@ -5442,6 +5648,12 @@
});
}
+ if (pinBtn) {
+ pinBtn.addEventListener('click', () => {
+ setCanopySidebarMiniPosition(canopySidebarRailState.miniPosition === 'bottom' ? 'top' : 'bottom');
+ });
+ }
+
const observerRoot = mainScroller || null;
state.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
diff --git a/canopy/ui/templates/base.html b/canopy/ui/templates/base.html
index a2b00fa..1f504e2 100644
--- a/canopy/ui/templates/base.html
+++ b/canopy/ui/templates/base.html
@@ -341,6 +341,10 @@
display: none !important;
}
+ .sidebar-utility-slot:empty {
+ display: none;
+ }
+
.sidebar-peers {
margin: 8px 12px 8px;
padding: 10px;
@@ -362,18 +366,148 @@
backdrop-filter: blur(12px) saturate(135%);
}
+ .sidebar-panel-heading {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .sidebar-panel-title {
+ min-width: 0;
+ }
+
+ .sidebar-panel-mode {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 1.3rem;
+ padding: 0.12rem 0.48rem;
+ border-radius: 999px;
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: var(--canopy-text-secondary);
+ font-size: 0.6rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+
+ .sidebar-panel-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ flex-shrink: 0;
+ }
+
+ .sidebar-panel-icon-btn,
+ .sidebar-media-mini-pin {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--canopy-text-secondary);
+ width: 28px;
+ height: 28px;
+ border-radius: 9px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+ }
+
+ .sidebar-panel-icon-btn:hover,
+ .sidebar-media-mini-pin:hover {
+ background: rgba(34, 197, 94, 0.12);
+ border-color: rgba(74, 222, 128, 0.35);
+ color: #dcfce7;
+ }
+
+ .sidebar-panel-icon-btn:focus-visible,
+ .sidebar-media-mini-pin:focus-visible {
+ outline: none;
+ background: rgba(34, 197, 94, 0.12);
+ border-color: rgba(74, 222, 128, 0.42);
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.22);
+ color: #dcfce7;
+ }
+
+ .sidebar-panel-footer {
+ margin-top: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ .sidebar-panel-footer[hidden] {
+ display: none !important;
+ }
+
+ .sidebar-panel-summary {
+ font-size: 0.65rem;
+ color: var(--canopy-text-muted);
+ letter-spacing: 0.02em;
+ }
+
+ .sidebar-panel-expand-btn {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--canopy-text-secondary);
+ padding: 0.35rem 0.6rem;
+ border-radius: 10px;
+ font-size: 0.68rem;
+ line-height: 1;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ transition: all 0.2s ease;
+ }
+
+ .sidebar-panel-expand-btn:hover {
+ background: rgba(34, 197, 94, 0.12);
+ border-color: rgba(74, 222, 128, 0.35);
+ color: #dcfce7;
+ }
+
+ .sidebar-panel-expand-btn:focus-visible {
+ outline: none;
+ background: rgba(34, 197, 94, 0.12);
+ border-color: rgba(74, 222, 128, 0.42);
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.22);
+ color: #dcfce7;
+ }
+
.sidebar-dm-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 7px;
- max-height: 232px;
- overflow-y: auto;
+ max-height: none;
+ overflow-y: visible;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
+ .sidebar-dm-contacts[data-view-state="collapsed"] .sidebar-dm-list,
+ .sidebar-peers[data-view-state="collapsed"] .sidebar-peer-list {
+ display: none;
+ }
+
+ .sidebar-dm-contacts[data-view-state="collapsed"],
+ .sidebar-peers[data-view-state="collapsed"] {
+ padding-bottom: 10px;
+ }
+
+ .sidebar-dm-contacts[data-view-state="expanded"] .sidebar-dm-list,
+ .sidebar-peers[data-view-state="expanded"] .sidebar-peer-list {
+ max-height: min(38vh, 360px);
+ overflow-y: auto;
+ padding-right: 2px;
+ }
+
.sidebar-dm-contact {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -557,6 +691,13 @@
margin-bottom: 8px;
}
+ .sidebar-media-mini-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ }
+
.sidebar-media-mini-icon {
width: 30px;
height: 30px;
@@ -746,6 +887,7 @@
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--canopy-text-muted);
+ gap: 0.65rem;
}
.sidebar-peers-count {
@@ -761,8 +903,8 @@
display: flex;
flex-direction: column;
gap: 7px;
- max-height: 272px;
- overflow-y: auto;
+ max-height: none;
+ overflow-y: visible;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-gutter: stable;
@@ -837,29 +979,6 @@
padding: 8px 4px;
}
- .sidebar-peer-more {
- margin-top: 4px;
- }
-
- .sidebar-peer-more-btn {
- width: 100%;
- border-radius: 10px;
- border: 1px dashed rgba(255, 255, 255, 0.12);
- background: rgba(255, 255, 255, 0.035);
- color: var(--canopy-text-muted);
- font-size: 0.72rem;
- font-weight: 600;
- letter-spacing: 0.03em;
- padding: 8px 10px;
- transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease;
- }
-
- .sidebar-peer-more-btn:hover {
- color: var(--canopy-text-primary);
- border-color: rgba(56, 189, 248, 0.28);
- background: rgba(56, 189, 248, 0.08);
- }
-
.peer-modal-search {
margin-bottom: 12px;
}
@@ -1516,21 +1635,20 @@
}
.sidebar-dm-list {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ display: flex;
+ flex-direction: column;
gap: 6px;
max-height: none;
}
.sidebar-dm-contact {
- grid-template-columns: auto 1fr;
- align-items: start;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
gap: 7px;
padding: 7px;
}
- .sidebar-dm-time,
- .sidebar-dm-preview {
+ .sidebar-dm-time {
display: none;
}
@@ -1546,12 +1664,17 @@
}
.sidebar-dm-name {
- font-size: 0.72rem;
+ font-size: 0.74rem;
line-height: 1.2;
- white-space: normal;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
+ }
+
+ .sidebar-dm-preview {
+ font-size: 0.64rem;
+ }
+
+ .sidebar-dm-contacts[data-view-state="expanded"] .sidebar-dm-list,
+ .sidebar-peers[data-view-state="expanded"] .sidebar-peer-list {
+ max-height: min(42vh, 300px);
}
.navbar {
@@ -5659,13 +5782,59 @@
{% endif %}
+