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

@@ -117,6 +117,40 @@ a:hover { text-decoration: underline; }
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
.app-body { display: flex; flex: 1; min-height: 0; }
/* ── Workspace (everything right of sidebar) ── */
.workspace {
display: flex; flex: 1; min-width: 0; min-height: 0;
overflow: hidden; position: relative;
}
.workspace-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.workspace-primary { flex: 1; min-width: 0; background: var(--bg); }
.workspace-secondary {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
transition: width 0.25s ease, min-width 0.25s ease;
}
.workspace-secondary.open {
width: 480px; min-width: 480px;
}
.workspace-secondary.fullscreen {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100% !important; min-width: 100% !important;
z-index: 100; transition: none;
}
.workspace-handle {
width: 0; flex-shrink: 0; position: relative; z-index: 10;
}
.workspace-handle.active {
width: 6px; cursor: col-resize;
}
.workspace-handle.active::after {
content: ''; position: absolute; inset: 0;
transition: background 0.15s;
}
.workspace-handle.active:hover::after { background: var(--accent); opacity: 0.3; }
.workspace-secondary.fullscreen ~ .workspace-handle { display: none; }
/* ── Environment Banners ──────────────────── */
.banner {
@@ -379,7 +413,8 @@ a:hover { text-decoration: underline; }
/* ── Chat Area ───────────────────────────── */
.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
/* .chat-area is now .workspace-primary — alias kept for compat */
.chat-area, .workspace-primary { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.model-bar {
display: flex; align-items: center; gap: 6px;
@@ -651,27 +686,9 @@ a:hover { text-decoration: underline; }
/* HTML preview */
/* ── Side Panel (Preview + Notes) ──────── */
.side-panel {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
display: flex; flex-direction: column; position: relative;
transition: width 0.25s ease, min-width 0.25s ease;
}
.side-panel.open {
width: 480px; min-width: 480px;
}
.side-panel.fullscreen {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100% !important; min-width: 100% !important;
z-index: 100;
transition: none;
}
.side-panel-resize {
position: absolute; left: -3px; top: 0; bottom: 0; width: 6px;
cursor: col-resize; z-index: 10;
}
.side-panel-resize:hover { background: var(--accent); opacity: 0.3; }
.side-panel.fullscreen .side-panel-resize { display: none; }
/* ── Secondary Pane (was .side-panel, now .workspace-secondary) ── */
/* Layout handled by .workspace-secondary above; these are internal styles */
.side-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
@@ -709,23 +726,23 @@ a:hover { text-decoration: underline; }
flex: 1; width: 100%; border: none; background: #fff;
}
/* Notes inside side panel */
/* Notes inside secondary pane */
.notes-actions-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.side-panel .notes-list { padding: 4px 8px; }
.side-panel .notes-editor { padding: 8px 12px; }
.side-panel .note-read-content {
.workspace-secondary .notes-list, .side-panel .notes-list { padding: 4px 8px; }
.workspace-secondary .notes-editor, .side-panel .notes-editor { padding: 8px 12px; }
.workspace-secondary .note-read-content, .side-panel .note-read-content {
max-height: none; flex: 1; overflow-y: auto;
}
.side-panel .notes-content-input {
.workspace-secondary .notes-content-input, .side-panel .notes-content-input {
min-height: 150px;
}
.side-panel #notesListView {
.workspace-secondary #notesListView, .side-panel #notesListView {
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel #notesEditorView {
.workspace-secondary #notesEditorView, .side-panel #notesEditorView {
flex: 1; overflow-y: auto; min-height: 0;
}
@@ -737,17 +754,11 @@ a:hover { text-decoration: underline; }
/* Mobile: full-width overlay */
@media (max-width: 768px) {
.side-panel.open {
.workspace-secondary.open {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100vw; min-width: 100vw; z-index: 200;
}
/* Disable dual-view on mobile — not enough space */
.side-panel-body.dual {
display: flex; flex-direction: column;
grid-template-columns: unset !important;
}
.side-panel-divider { display: none !important; }
.side-panel-dual-btn { display: none; }
.workspace-handle { display: none !important; }
/* Larger touch targets on mobile */
.side-panel-close { font-size: 20px; padding: 4px 10px; }
@@ -755,33 +766,8 @@ a:hover { text-decoration: underline; }
.side-panel-header { padding: 10px 12px; }
}
/* ── Dual-View Mode ───────────────────────── */
.side-panel.dual-open {
width: 680px; min-width: 680px;
}
.side-panel-body.dual {
display: grid;
grid-template-columns: 1fr 6px 1fr;
overflow: hidden;
}
.side-panel-body.dual > .side-panel-page {
overflow-y: auto; min-width: 0; min-height: 0;
}
.side-panel-divider {
grid-column: 2;
width: 6px; cursor: col-resize;
background: var(--border);
transition: background 0.15s;
flex-shrink: 0;
}
.side-panel-divider:hover,
.side-panel-divider:active {
background: var(--accent); opacity: 0.5;
}
.side-panel-dual-btn.active {
background: var(--accent); color: var(--bg);
}
/* Dual-view removed in workspace refactor (v0.22.0) — workspace
handles all pane placement via primary/secondary slots. */
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.msg-text li { margin: 0.2em 0; }

View File

@@ -138,8 +138,11 @@
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main Chat Area -->
<main class="chat-area">
<!-- ── Workspace (all content panes) ────── -->
<div class="workspace" id="workspace">
<!-- Primary Pane (chat by default, surfaces can swap) -->
<main class="workspace-pane workspace-primary" id="workspacePrimary">
<div class="model-bar" data-surface-region="surface-header">
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
@@ -222,16 +225,15 @@
</div>
</main>
<!-- Side Panel (registry-driven, see panels.js) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<!-- Workspace Resize Handle (between primary and secondary) -->
<div class="workspace-handle" id="workspaceHandle"></div>
<!-- Secondary Pane (registry-driven, see panels.js) -->
<aside class="workspace-pane workspace-secondary" id="workspaceSecondary">
<div class="side-panel-header">
<span class="side-panel-label" id="sidePanelLabel"></span>
<div class="side-panel-actions">
<span id="sidePanelPanelActions"></span>
<button class="side-panel-action-btn side-panel-dual-btn" id="sidePanelDualBtn" onclick="PanelRegistry.toggleDual()" title="Toggle split view" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
</button>
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
@@ -239,8 +241,6 @@
</div>
</div>
<div class="side-panel-body" id="sidePanelBody">
<!-- Dual-view divider (hidden by default, shown in dual mode) -->
<div class="side-panel-divider" id="sidePanelDivider" style="display:none"></div>
<!-- Preview panel -->
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
<div class="side-panel-empty" id="previewEmpty">
@@ -344,6 +344,8 @@
</div>
</div>
</aside>
</div><!-- .workspace -->
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
</div><!-- .app-body -->

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');