245 lines
9.4 KiB
Markdown
245 lines
9.4 KiB
Markdown
# 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-page` divs toggled by display
|
||
|
||
**JS (ui-format.js lines 295–365):**
|
||
- `openSidePanel(tab)` — adds `.open` class, calls `switchSidePanelTab`
|
||
- `closeSidePanel()` — removes `.open` and `.fullscreen`, clears inline width
|
||
- `switchSidePanelTab(tab)` — toggles active class on tab buttons, toggles display on page divs
|
||
- `_initSidePanelResize()` — mousedown/move/up drag handler for width resize
|
||
|
||
**Callers (4 total):**
|
||
1. `toggleHTMLPreview()` → `openSidePanel('preview')` (ui-format.js)
|
||
2. `openNotes()` → `openSidePanel('notes')` / `closeSidePanel()` (notes.js)
|
||
3. Escape handler → `closeSidePanel()` (app.js)
|
||
4. Inline onclick handlers in index.html (tab buttons, close, fullscreen, clear)
|
||
|
||
**CSS (styles.css lines 642–737):**
|
||
- `.side-panel` base: `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.
|
||
|
||
```js
|
||
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:**
|
||
|
||
```js
|
||
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:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```js
|
||
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's `srcdoc`. 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 CSS `transform:
|
||
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` (replace `openSidePanel`/`closeSidePanel` calls with `PanelRegistry.open`/`close`)
|
||
- Modified: `src/js/app.js` (update Escape handler, add keyboard shortcuts, replace `_initSidePanelResize` call)
|
||
- Modified: `src/index.html` (remove hardcoded tab buttons, add `panels.js` script tag, make tab bar dynamic)
|
||
- No CSS changes (reuse existing `.side-panel-tab` styles)
|
||
|
||
**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 to `panels.js` (operates on container)
|
||
- [ ] Escape handler: `PanelRegistry.closeAll()`
|
||
- [ ] `openNotes()` toggle logic: `PanelRegistry.toggle('notes')`
|
||
- [ ] `toggleHTMLPreview()`: `PanelRegistry.open('preview')` + update iframe content
|