Changeset 0.22.0.1 (#94)
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
341
src/js/panels.js
341
src/js/panels.js
@@ -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, // 0–1, 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user