Changeset 0.22.0.1 (#94)

This commit is contained in:
2026-03-02 01:55:19 +00:00
parent 12e03cec8b
commit 06c4e2a5a1
33 changed files with 1929 additions and 418 deletions

View File

@@ -450,10 +450,9 @@ function initListeners() {
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag
_initWorkspaceResize(); // from panels.js — workspace handle drag
_initPanelSwipe(); // from panels.js — mobile swipe navigation
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
_initPanelResponsive(); // from panels.js — responsive overlay
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
}
@@ -516,19 +515,12 @@ function _initGlobalKeyboard() {
}
// Ctrl/Cmd+\: cycle side panels
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === '\\') {
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
PanelRegistry.cycle();
return;
}
// Ctrl/Cmd+Shift+\: toggle dual-view split
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '\\') {
e.preventDefault();
PanelRegistry.toggleDual();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();

View File

@@ -143,6 +143,10 @@ const EditorMode = {
regions: ['surface-header', 'surface-main'],
activate: () => this._activate(),
deactivate: () => this._deactivate(),
// Layout: editor is primary, chat in secondary (handled internally)
primary: 'editor',
secondary: 'chat',
secondaryOpts: ['chat'],
});
// Show the Files tab in sidebar and populate it

View File

@@ -1,25 +1,25 @@
// ==========================================
// Chat Switchboard Panel Registry
// Chat Switchboard Panel Registry (v0.22.0)
// ==========================================
// Independent panel system replacing the hardcoded two-tab side panel.
// Workspace-aware panel system. Panels are shown inside the
// workspace secondary pane (.workspace-secondary). The workspace
// owns the resize handle between primary and secondary.
//
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the <aside> container directly.
// never touch the secondary container directly.
//
// Supports single-panel and dual-view modes. In dual mode the panel
// body splits into primary (left) + divider + secondary (right) via
// CSS grid. The split ratio is drag-adjustable.
//
// See DESIGN-0.18.1.md for full spec.
// Layout model (v0.22.0):
// .workspace
// .workspace-primary ← main content (chat / notes / editor)
// .workspace-handle ← drag-resize between panes
// .workspace-secondary ← panel pages (preview / notes / project)
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active: null, // primary (left) panel name
_secondary: null, // secondary (right) panel name — null when not in dual mode
_dualMode: false,
_splitRatio: 0.5, // 01, fraction of space allocated to primary
_active: null, // currently visible panel name
_order: [], // registration order (for tab rendering + cycle)
/**
@@ -27,7 +27,7 @@ const PanelRegistry = {
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
* @param {object} opts
* @param {HTMLElement} opts.element — the .side-panel-page div
* @param {string} opts.label — display name for tab button
* @param {string} opts.label — display name
* @param {Function} [opts.onOpen] — called when panel becomes visible
* @param {Function} [opts.onClose] — called when panel is hidden
* @param {Function} [opts.saveState] — returns state snapshot before hiding
@@ -56,12 +56,8 @@ const PanelRegistry = {
},
/**
* Open a panel (and the container if closed).
*
* Single mode: hides current active, shows requested panel.
* Dual mode:
* - Already visible (active or secondary): swap it to primary.
* - Not visible: replaces secondary panel.
* Open a panel (and the secondary pane if collapsed).
* Hides current active panel, shows the requested one.
*/
open(name) {
const panel = this._panels[name];
@@ -73,39 +69,21 @@ const PanelRegistry = {
const container = this._container();
if (!container) return;
if (this._dualMode) {
// Already primary — no-op
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// Currently secondary — promote to primary, swap
if (this._secondary === name) {
const oldActive = this._active;
this._active = name;
this._secondary = oldActive;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// New panel — replace secondary
if (this._secondary) this._hide(this._secondary);
this._show(name);
this._secondary = name;
this._applyDualLayout();
// Already active — just refresh label/actions
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// ── Single mode ──
// Hide current active
if (this._active && this._active !== name) {
this._hide(this._active);
}
// Show the secondary pane
container.classList.add('open');
this._showHandle(true);
this._show(name);
this._active = name;
this._showOverlay();
@@ -114,38 +92,12 @@ const PanelRegistry = {
},
/**
* Close a specific panel.
*
* Dual mode: closing either panel exits dual, keeping the other.
* Single mode: closes container.
* Close a specific panel. If it's active, close the secondary pane.
*/
close(name) {
const panel = this._panels[name];
if (!panel) return;
if (this._dualMode) {
if (name === this._secondary) {
this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
if (name === this._active) {
this._hide(this._active);
// Promote secondary to primary
this._active = this._secondary;
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
return; // not visible — nothing to close
}
// ── Single mode ──
if (this._active === name) {
this._hide(name);
this._active = null;
@@ -165,89 +117,34 @@ const PanelRegistry = {
},
/**
* Close all panels and the container.
* Close all panels and the secondary pane.
*/
closeAll() {
if (this._secondary) {
this._hide(this._secondary);
this._secondary = null;
}
if (this._active) {
this._hide(this._active);
this._active = null;
}
if (this._dualMode) this._exitDualLayout();
this._closeContainer();
},
/** Is a specific panel currently visible? */
isOpen(name) {
return this._active === name || this._secondary === name;
return this._active === name;
},
/** Is the panel container open at all? */
/** Is the secondary pane open at all? */
isContainerOpen() {
return this._container()?.classList.contains('open') || false;
},
/** Get the primary panel name (or null). */
/** Get the active panel name (or null). */
active() {
return this._active;
},
/** Is dual-view mode active? */
isDual() {
return this._dualMode;
},
/**
* Toggle dual-view mode.
*
* Entering: picks the next registered panel as secondary. If
* container is closed, opens first two panels. Needs ≥ 2 panels.
* Exiting: hides secondary, keeps primary.
*/
toggleDual() {
if (this._dualMode) {
// Exit dual
if (this._secondary) this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Enter dual — need at least 2 panels
if (this._order.length < 2) return;
const container = this._container();
if (!container) return;
// If container not open, open first panel as primary
if (!this._active) {
container.classList.add('open');
this._show(this._order[0]);
this._active = this._order[0];
}
// Pick secondary: first registered panel that isn't active
const secondary = this._order.find(n => n !== this._active);
if (!secondary) return;
this._show(secondary);
this._secondary = secondary;
this._dualMode = true;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
},
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*
* In dual mode, cycles the secondary panel through non-primary panels.
*/
cycle() {
if (this._order.length === 0) return;
@@ -257,23 +154,6 @@ const PanelRegistry = {
return;
}
if (this._dualMode && this._secondary) {
// Cycle secondary through panels that aren't the primary
const others = this._order.filter(n => n !== this._active);
if (others.length < 2) return; // only one option, already showing
const idx = others.indexOf(this._secondary);
const next = others[(idx + 1) % others.length];
if (next === this._secondary) return;
this._hide(this._secondary);
this._show(next);
this._secondary = next;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Single mode: cycle active
const idx = this._order.indexOf(this._active);
const next = this._order[(idx + 1) % this._order.length];
this.open(next);
@@ -281,8 +161,9 @@ const PanelRegistry = {
// ── Internal ────────────────────────────
/** The secondary pane element. */
_container() {
return document.getElementById('sidePanel');
return document.getElementById('workspaceSecondary');
},
_body() {
@@ -304,7 +185,7 @@ const PanelRegistry = {
if (panel.onOpen) panel.onOpen();
},
/** Hide a panel: save state, call onClose, set display:none, clear grid placement. */
/** Hide a panel: save state, call onClose, set display:none. */
_hide(name) {
const panel = this._panels[name];
if (!panel) return;
@@ -316,68 +197,23 @@ const PanelRegistry = {
if (panel.onClose) panel.onClose();
panel.element.style.display = 'none';
panel.element.style.gridColumn = '';
},
/** Close the <aside> container. */
/** Close the secondary pane. */
_closeContainer() {
const container = this._container();
if (!container) return;
container.classList.remove('open', 'fullscreen', 'dual-open');
container.classList.remove('open', 'fullscreen');
container.style.width = '';
container.style.minWidth = '';
this._showHandle(false);
this._hideOverlay();
},
// ── Dual layout ─────────────────────────
/** Apply CSS grid layout for dual-view. */
_applyDualLayout() {
this._dualMode = true;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (!body || !container) return;
body.classList.add('dual');
container.classList.add('dual-open');
if (divider) divider.style.display = '';
// Assign grid columns: primary=1, divider=2, secondary=3
const primary = this._panels[this._active];
const secondary = this._panels[this._secondary];
if (primary) primary.element.style.gridColumn = '1';
if (secondary) secondary.element.style.gridColumn = '3';
this._applySplitRatio();
},
/** Remove dual layout, return to single-panel flexbox. */
_exitDualLayout() {
this._dualMode = false;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (body) {
body.classList.remove('dual');
body.style.gridTemplateColumns = '';
}
if (container) container.classList.remove('dual-open');
if (divider) divider.style.display = 'none';
// Clear grid-column on all panels
for (const name of this._order) {
const p = this._panels[name];
if (p) p.element.style.gridColumn = '';
}
},
/** Set grid-template-columns based on current _splitRatio. */
_applySplitRatio() {
const body = this._body();
if (!body || !this._dualMode) return;
const r = this._splitRatio;
body.style.gridTemplateColumns = `${r}fr 6px ${1 - r}fr`;
/** Show/hide the workspace resize handle. */
_showHandle(show) {
const handle = document.getElementById('workspaceHandle');
if (handle) handle.classList.toggle('active', show);
},
// ── Mobile overlay ──────────────────────
@@ -386,7 +222,7 @@ const PanelRegistry = {
return window.innerWidth <= 768;
},
/** Show overlay behind panel on mobile (tap-to-close). */
/** Show overlay behind secondary on mobile (tap-to-close). */
_showOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov && this._isMobile()) ov.style.display = 'block';
@@ -398,28 +234,22 @@ const PanelRegistry = {
if (ov) ov.style.display = 'none';
},
// ── Tab and action rendering ────────────
// ── Label and action rendering ──────────
/** Update the header label to show the active panel name. */
_renderLabel() {
const el = document.getElementById('sidePanelLabel');
if (!el) return;
if (this._active) {
const p = this._panels[this._active];
el.textContent = p ? p.label : '';
} else {
el.textContent = '';
}
// Update dual-view toggle button visibility
const dualBtn = document.getElementById('sidePanelDualBtn');
if (dualBtn) {
dualBtn.style.display = this._order.length >= 2 ? '' : 'none';
dualBtn.classList.toggle('active', this._dualMode);
}
},
/** Render per-panel action buttons for the primary (active) panel. */
/** Render per-panel action buttons for the active panel. */
_renderActions() {
const slot = document.getElementById('sidePanelPanelActions');
if (!slot) return;
@@ -448,10 +278,10 @@ function _escPanel(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Side Panel Container (global operations) ──
// ── Secondary Pane Fullscreen ──────────────
function toggleSidePanelFullscreen() {
const panel = document.getElementById('sidePanel');
const panel = document.getElementById('workspaceSecondary');
if (!panel) return;
panel.classList.toggle('fullscreen');
const btn = document.getElementById('sidePanelFullscreenBtn');
@@ -464,77 +294,49 @@ function toggleSidePanelFullscreen() {
}
}
// ── Side Panel Resize (outer edge) ──────────
// ── Workspace Resize Handle ────────────────
function _initSidePanelResize() {
function _initWorkspaceResize() {
let startX, startW;
const panel = document.getElementById('sidePanel');
const handle = document.getElementById('sidePanelResize');
if (!handle || !panel) return;
const secondary = document.getElementById('workspaceSecondary');
const handle = document.getElementById('workspaceHandle');
if (!handle || !secondary) return;
handle.addEventListener('mousedown', (e) => {
if (panel.classList.contains('fullscreen')) return;
const onDown = (e) => {
if (!handle.classList.contains('active')) return;
if (secondary.classList.contains('fullscreen')) return;
e.preventDefault();
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
panel.style.transition = 'none';
const clientX = e.clientX ?? e.touches?.[0]?.clientX;
startX = clientX;
startW = secondary.getBoundingClientRect().width;
secondary.style.transition = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const delta = startX - e.clientX;
const minW = PanelRegistry.isDual() ? 480 : 280;
const newW = Math.max(minW, Math.min(window.innerWidth * 0.7, startW + delta));
panel.style.width = newW + 'px';
panel.style.minWidth = newW + 'px';
const cx = e.clientX ?? e.touches?.[0]?.clientX ?? startX;
const delta = startX - cx;
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
secondary.style.width = newW + 'px';
secondary.style.minWidth = newW + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
panel.style.transition = '';
secondary.style.transition = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
};
// ── Dual-View Divider Resize ────────────────
function _initDualDivider() {
const divider = document.getElementById('sidePanelDivider');
if (!divider) return;
divider.addEventListener('mousedown', (e) => {
if (!PanelRegistry.isDual()) return;
e.preventDefault();
const body = document.getElementById('sidePanelBody');
if (!body) return;
const bodyRect = body.getBoundingClientRect();
body.style.cursor = 'col-resize';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const x = e.clientX - bodyRect.left;
const total = bodyRect.width - 6; // subtract divider width
const ratio = Math.max(0.2, Math.min(0.8, x / (total + 6)));
PanelRegistry._splitRatio = ratio;
PanelRegistry._applySplitRatio();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
body.style.cursor = '';
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
}
// ── Mobile Swipe Navigation ─────────────────
@@ -550,7 +352,6 @@ function _initPanelSwipe() {
body.addEventListener('touchstart', (e) => {
if (!PanelRegistry.isContainerOpen()) return;
if (PanelRegistry._order.length < 2) return;
// Only track single-finger swipes
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX;
@@ -572,10 +373,8 @@ function _initPanelSwipe() {
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0) {
// Swipe right → previous panel
_cyclePanelDirection(-1);
} else {
// Swipe left → next panel
_cyclePanelDirection(1);
}
}, { passive: true });
@@ -595,7 +394,6 @@ function _cyclePanelDirection(direction) {
}
// ── Responsive Resize Handler ───────────────
// Auto-exit dual mode when viewport shrinks below threshold.
function _initPanelResponsive() {
let wasWide = window.innerWidth > 768;
@@ -603,11 +401,6 @@ function _initPanelResponsive() {
window.addEventListener('resize', () => {
const isWide = window.innerWidth > 768;
// Viewport shrank to mobile while dual was active → collapse
if (wasWide && !isWide && PanelRegistry.isDual()) {
PanelRegistry.toggleDual(); // exits dual, keeps primary
}
// Update overlay visibility: show on mobile if panel open, hide on desktop
if (PanelRegistry.isContainerOpen()) {
const ov = document.getElementById('sidePanelOverlay');
@@ -631,7 +424,7 @@ function _initPanelOverlay() {
});
}
// ── Legacy Compat (thin wrappers, remove after full migration) ──
// ── Legacy Compat (thin wrappers) ───────────
function openSidePanel(tab) {
PanelRegistry.open(tab);

View File

@@ -41,6 +41,10 @@ const Surfaces = {
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
// Layout declarations (v0.22.0)
primary: 'chat',
secondary: 'preview',
secondaryOpts: ['preview', 'notes', 'project'],
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
@@ -51,7 +55,13 @@ const Surfaces = {
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate() }
* @param {object} opts — { label, icon, regions[], activate(), deactivate(),
* primary?, secondary?, secondaryOpts? }
*
* Layout declarations (v0.22.0):
* primary — pane id that owns the workspace-primary slot ('chat', 'notes', 'editor')
* secondary — default pane id for workspace-secondary (null = hidden)
* secondaryOpts — array of pane ids allowed in secondary for this surface
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
@@ -65,6 +75,9 @@ const Surfaces = {
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
primary: opts.primary || id,
secondary: opts.secondary || null,
secondaryOpts: opts.secondaryOpts || [],
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
@@ -167,6 +180,20 @@ const Surfaces = {
return this._registry.size > 1;
},
/**
* Get the layout declarations for the current surface.
* Returns { primary, secondary, secondaryOpts } or defaults.
*/
getLayout(id) {
const def = this._registry.get(id || this._current);
if (!def) return { primary: 'chat', secondary: null, secondaryOpts: [] };
return {
primary: def.primary || id || 'chat',
secondary: def.secondary || null,
secondaryOpts: def.secondaryOpts || [],
};
},
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM

View File

@@ -118,7 +118,7 @@ Object.assign(UI, {
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay, .side-panel, .admin-panel').forEach(el => el.style.zoom = z);
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');