9.4 KiB
Design — v0.18.1 Side Panel Architecture
Problem
The side panel is a single <aside> with two mutually exclusive tabs
(Preview and Notes) toggled by display: none. Opening Notes kills the
preview and vice versa. As more panel content arrives (KB browser,
search results, memory viewer), the single-slot model doesn't scale.
The current implementation also loses per-panel state (scroll position,
active note, preview content) on tab switches.
This is also prerequisite plumbing for v0.23.0 multi-participant channels, where multiple panels need simultaneous visibility.
Current State
HTML: Single <aside id="sidePanel"> containing:
- Resize handle (
sidePanelResize) - Header with tab bar (Preview | Notes) + action buttons (clear, fullscreen, close)
- Body with two
.side-panel-pagedivs toggled by display
JS (ui-format.js lines 295–365):
openSidePanel(tab)— adds.openclass, callsswitchSidePanelTabcloseSidePanel()— removes.openand.fullscreen, clears inline widthswitchSidePanelTab(tab)— toggles active class on tab buttons, toggles display on page divs_initSidePanelResize()— mousedown/move/up drag handler for width resize
Callers (4 total):
toggleHTMLPreview()→openSidePanel('preview')(ui-format.js)openNotes()→openSidePanel('notes')/closeSidePanel()(notes.js)- Escape handler →
closeSidePanel()(app.js) - Inline onclick handlers in index.html (tab buttons, close, fullscreen, clear)
CSS (styles.css lines 642–737):
.side-panelbase:width: 0; min-width: 0; overflow: hidden, transition on width.side-panel.open:width: 480px; min-width: 480px.side-panel.fullscreen: fixed positioning, 100% width- Mobile (
@media max-width: 768px): open = fixed overlay at 100vw
Design
Panel Registry
A lightweight registry in a new panels.js file. Each panel is a named
entry with its own state and DOM container. The registry owns all
open/close/focus logic — individual panels never touch the <aside>
directly.
const PanelRegistry = {
_panels: {}, // { name: { element, onOpen, onClose, onFocus, state } }
_active: null, // currently visible panel name (single-view mode)
_secondary: null, // second visible panel (dual-view mode)
_dualMode: false,
register(name, opts) { ... },
open(name) { ... },
close(name) { ... },
toggle(name) { ... },
closeAll() { ... },
isOpen(name) { ... },
active() { ... },
};
Panel registration contract:
PanelRegistry.register('notes', {
element: document.getElementById('sidePanelNotes'),
onOpen() { loadNotesList(); loadNoteFolders(); },
onClose() { /* optional cleanup */ },
onFocus() { /* optional re-activate */ },
state: {}, // panel-specific state bag (scroll pos, etc.)
});
Panels register themselves during initialization — Notes registers in
notes.js, Preview registers in ui-format.js. This keeps domain
logic co-located with its panel.
DOM Structure
Replace the hardcoded tab bar with a registry-driven header. The
<aside> structure stays identical in shape (no layout break), but
tabs render dynamically from registered panels:
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<!-- rendered by PanelRegistry from registered panels -->
</div>
<div class="side-panel-actions" id="sidePanelActions">
<!-- per-panel action buttons injected by active panel -->
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" ...>
<button class="side-panel-close" ...>
</div>
</div>
<div class="side-panel-body" id="sidePanelBody">
<!-- all .side-panel-page divs, shown/hidden by registry -->
</div>
</aside>
The tab bar is re-rendered on register() and on open()/close().
Each panel can declare actions — an array of button configs that
appear in the header when that panel is active (replacing the current
hardcoded clear button for preview).
State Preservation
Each panel gets a state bag on its registry entry. Before hiding a
panel, the registry calls a saveState() hook (if provided) which can
snapshot scroll position, selection, etc. On re-show, restoreState()
re-applies. This is opt-in — panels that don't need it don't implement
the hooks.
Notes state: { scrollTop, editingNoteId, viewMode: 'list'|'editor'|'graph' }
Preview state: { srcdoc, hasContent }
Dual-View Mode (Phase 2)
When enabled, the panel body splits into two panes with an internal
divider. _active shows on the left (or top on mobile), _secondary
on the right (or bottom). The outer resize handle still controls total
panel width; an inner divider controls the split ratio.
Dual mode activates via:
Ctrl/Cmd+Shift+\keyboard shortcut- Dragging a tab to the other half (stretch goal)
Per-Panel Action Buttons
Currently Preview has a "Clear" button and both panels share Fullscreen and Close. The new model:
- Global actions (always visible): Fullscreen, Close
- Panel actions (per-panel, in active panel's header slot):
- Preview: Clear
- Notes: (none currently, but room for "New Note" shortcut)
- Future panels declare their own
Panels declare actions at registration time:
PanelRegistry.register('preview', {
element: document.getElementById('sidePanelPreview'),
actions: [
{ id: 'sidePanelClearBtn', icon: '...svg...', title: 'Clear preview',
onClick: clearPreview, visible: () => hasPreviewContent() },
],
});
Preview Enhancements (Phase 3)
- Live-update during streaming: The chat streaming handler already
accumulates HTML code blocks. On each chunk that completes a fenced
code block tagged
html, if the preview panel is open, update the iframe'ssrcdoc. Debounce at 500ms to avoid thrashing. - Extension block preview: Mermaid, KaTeX, etc. render inline in chat. Add a "Pop out" button on extension-rendered blocks that opens the rendered output in the preview panel (full-size, zoomable).
Mobile (Phase 4)
- Swipe navigation: Touch event handlers on
.side-panel-body— swipe left/right cycles through open panels. Uses CSStransform: translateX()with a 150px threshold for commit. - Bottom sheet mode: On viewports < 768px, the secondary panel in dual mode renders as a bottom sheet (40% height) instead of side-by- side.
- Priority collapse: When space is tight and dual mode can't fit (< 600px combined), collapse to single-panel with most-recently- opened winning. The displaced panel becomes accessible via tab bar.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl/Cmd+\ |
Cycle active panel (or open panel container if closed) |
Ctrl/Cmd+Shift+\ |
Toggle dual-view mode |
Escape |
Close panel container (existing behavior, unchanged) |
Phases
Phase 1 — Panel Registry + State Management
Extract panel logic from ui-format.js into panels.js. Implement
PanelRegistry with register/open/close/toggle/closeAll. Migrate
Notes and Preview to register as panels. Dynamic tab bar rendering.
Per-panel action buttons. State preservation hooks.
Files changed:
- New:
src/js/panels.js - Modified:
src/js/ui-format.js(remove panel functions, add preview registration) - Modified:
src/js/notes.js(replaceopenSidePanel/closeSidePanelcalls withPanelRegistry.open/close) - Modified:
src/js/app.js(update Escape handler, add keyboard shortcuts, replace_initSidePanelResizecall) - Modified:
src/index.html(remove hardcoded tab buttons, addpanels.jsscript tag, make tab bar dynamic) - No CSS changes (reuse existing
.side-panel-tabstyles)
Backward compatibility: All existing panel behavior works identically.
openSidePanel() and closeSidePanel() become thin wrappers around
PanelRegistry during transition, then get removed after all callers
migrate.
Phase 2 — Dual-View Layout
CSS grid split inside .side-panel-body. Internal divider with drag
resize. Keyboard shortcut for dual toggle. Tab drag to split (stretch).
Files changed:
- Modified:
src/js/panels.js(dual-view logic) - Modified:
src/css/styles.css(grid layout for dual panes)
Phase 3 — Preview Enhancements
Live-update preview during streaming. Extension block "pop out" button.
Files changed:
- Modified:
src/js/ui-format.js(pop-out button on extension blocks) - Modified:
src/js/chat.js(streaming hook for live preview update) - Modified:
src/js/panels.js(debounced preview update API)
Phase 4 — Mobile
Swipe navigation, bottom sheet mode, priority collapse.
Files changed:
- Modified:
src/js/panels.js(touch handlers, priority logic) - Modified:
src/css/styles.css(bottom sheet layout, responsive breakpoints)
Migration Checklist
openSidePanel(tab)→PanelRegistry.open(tab)closeSidePanel()→PanelRegistry.closeAll()switchSidePanelTab(tab)→PanelRegistry.open(tab)(open implies focus)toggleSidePanelFullscreen()→ stays global (operates on container, not individual panels)_initSidePanelResize()→ moves topanels.js(operates on container)- Escape handler:
PanelRegistry.closeAll() openNotes()toggle logic:PanelRegistry.toggle('notes')toggleHTMLPreview():PanelRegistry.open('preview')+ update iframe content