web+shell: draggable divider for the desktop Chat|Interface split view (#626)
Adds #app-split-divider between #app-pane and #interface-pane, draggable (pointer events) and keyboard-operable (arrow/Home/End/Enter), with the ratio persisted as a local preference (same pattern as theme/text-size) and applied via a --split-ratio CSS custom property. Desktop-only, matching Split's existing desktop-only scope.
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+95
−0
web/pages/index.html+1 −0web/static/shell.js+64 −0web/static/style.css+30 −0
diff --git a/web/pages/index.html b/web/pages/index.html| index d083d27..023b3b1 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -213,6 +213,7 @@ |
| chat pane; #interface-pane stays empty (and hidden). --> |
| <div class="app-split" id="app-split"> |
| <div class="app-pane" id="app-pane"></div> |
| + <div class="app-split-divider" id="app-split-divider" role="separator" aria-orientation="vertical" aria-label="Resize chat and interface panes" aria-valuemin="20" aria-valuemax="80" tabindex="0"></div> |
| <div class="app-pane interface-pane" id="interface-pane"></div> |
| </div> |
| <div class="feed" id="feed"></div> |
diff --git a/web/static/shell.js b/web/static/shell.js| index 31d07c7..af191a9 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -249,6 +249,69 @@ function setTextSize(px) { |
| } |
| applyTextSize(currentTextSize()); |
| +// ---- split view divider ---------------------------------------------------- |
| +// Desktop-only Chat|Interface split (see the [data-app-view="split"] CSS): |
| +// #app-split-divider lets a member drag the boundary between #app-pane and |
| +// #interface-pane. The ratio is a local preference, same pattern as the theme |
| +// and text size — applied as a CSS custom property the split CSS reads. |
| +const SPLIT_RATIO_KEY = "lchat_split_ratio"; |
| +const SPLIT_RATIO_MIN = 0.2; |
| +const SPLIT_RATIO_MAX = 0.8; |
| +const clampSplitRatio = (r) => Math.min(SPLIT_RATIO_MAX, Math.max(SPLIT_RATIO_MIN, r)); |
| +const currentSplitRatio = () => { |
| + const v = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY)); |
| + return Number.isFinite(v) ? clampSplitRatio(v) : 0.5; |
| +}; |
| +function applySplitRatio(ratio) { |
| + document.documentElement.style.setProperty("--split-ratio", String(ratio)); |
| + const divider = document.getElementById("app-split-divider"); |
| + if (divider) divider.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); |
| +} |
| +function setSplitRatio(ratio) { |
| + ratio = clampSplitRatio(ratio); |
| + localStorage.setItem(SPLIT_RATIO_KEY, String(ratio)); |
| + applySplitRatio(ratio); |
| +} |
| +// #app-split-divider lives inside #app-tpl (cloned into the DOM by renderApp, |
| +// below), so it doesn't exist yet at module load — applySplitRatio's element |
| +// lookup is a no-op until renderApp calls wireSplitDivider(). |
| +function wireSplitDivider() { |
| + const divider = document.getElementById("app-split-divider"); |
| + const container = document.getElementById("app-split"); |
| + if (!divider || !container) return; |
| + applySplitRatio(currentSplitRatio()); |
| + const step = 0.02; |
| + let startX = 0; |
| + let startRatio = 0.5; |
| + divider.addEventListener("pointerdown", (e) => { |
| + divider.setPointerCapture?.(e.pointerId); |
| + startX = e.clientX; |
| + startRatio = currentSplitRatio(); |
| + divider.classList.add("dragging"); |
| + }); |
| + divider.addEventListener("pointermove", (e) => { |
| + if (!divider.classList.contains("dragging")) return; |
| + const rect = container.getBoundingClientRect(); |
| + if (rect.width) setSplitRatio(startRatio + (e.clientX - startX) / rect.width); |
| + }); |
| + const endDrag = (e) => { |
| + divider.classList.remove("dragging"); |
| + divider.releasePointerCapture?.(e.pointerId); |
| + }; |
| + divider.addEventListener("pointerup", endDrag); |
| + divider.addEventListener("pointercancel", endDrag); |
| + divider.addEventListener("dblclick", () => setSplitRatio(0.5)); |
| + divider.addEventListener("keydown", (e) => { |
| + if (e.key === "ArrowLeft") setSplitRatio(currentSplitRatio() - step); |
| + else if (e.key === "ArrowRight") setSplitRatio(currentSplitRatio() + step); |
| + else if (e.key === "Home") setSplitRatio(SPLIT_RATIO_MIN); |
| + else if (e.key === "End") setSplitRatio(SPLIT_RATIO_MAX); |
| + else if (e.key === "Enter") setSplitRatio(0.5); |
| + else return; |
| + e.preventDefault(); |
| + }); |
| +} |
| + |
| // ---- action chips --------------------------------------------------------- |
| // One-tap buttons a helper attaches to its reply ("Switch to dark" etc.). |
| // Consent-based by design: nothing runs until the member taps — payloads |
| @@ -2953,6 +3016,7 @@ function renderApp() { |
| document.getElementById("new-chat").onclick = showNewChat; |
| document.getElementById("back-btn").onclick = showList; |
| + wireSplitDivider(); |
| // Filter the chat list as you type (name match only; never sent anywhere) — |
| // and search MESSAGES too (#586): matches from the local store render below |
diff --git a/web/static/style.css b/web/static/style.css| index 1ee3439..26f7d1f 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -2315,6 +2315,7 @@ button.danger { background: transparent; border: 1px solid var(--danger); color: |
| .thread-view.app-mode #app-split { display: flex; } |
| #interface-pane:empty { display: none; } /* a normal chat has no interface pane */ |
| .view-toggle-btn.split-only { display: none; } /* Split is desktop-only (revealed below) */ |
| +.app-split-divider { display: none; } /* only ever shown in split view on desktop, below */ |
| @media (min-width: 721px) { |
| /* Desktop: three views — chat | split (both, the default) | interface. */ |
| #app-split { flex-direction: row; } |
| @@ -2322,6 +2323,35 @@ button.danger { background: transparent; border: 1px solid var(--danger); color: |
| .view-toggle-btn.split-only { display: inline-flex; } |
| .thread-view[data-app-view="chat"] #interface-pane { display: none; } |
| .thread-view[data-app-view="interface"] #app-pane { display: none; } |
| + /* Split: the divider between the panes is draggable (mouse/touch/pen) and |
| + keyboard-operable (arrow keys), and owns the separating line — so the |
| + plain border above is switched off here in favour of the divider's own. */ |
| + .thread-view[data-app-view="split"] #app-pane { |
| + flex: 0 0 auto; |
| + width: calc(var(--split-ratio, 0.5) * 100%); |
| + } |
| + .thread-view[data-app-view="split"] #interface-pane { |
| + flex: 1 1 auto; |
| + border-left: none; |
| + } |
| + .thread-view[data-app-view="split"] #app-split-divider { |
| + display: flex; align-items: center; justify-content: center; |
| + flex: 0 0 11px; width: 11px; min-height: 0; |
| + cursor: col-resize; touch-action: none; position: relative; |
| + } |
| + .app-split-divider::before { |
| + content: ""; position: absolute; top: 0; bottom: 0; left: 50%; |
| + width: 1px; background: var(--border); |
| + } |
| + .app-split-divider::after { |
| + content: ""; width: 3px; height: 32px; border-radius: 2px; |
| + background: var(--border); |
| + } |
| + .app-split-divider:hover::after, |
| + .app-split-divider:focus-visible::after, |
| + .app-split-divider.dragging::after { background: var(--muted); } |
| + .app-split-divider:focus-visible { outline: none; } /* the grip itself shows focus */ |
| + .app-split-divider:focus-visible::after { box-shadow: 0 0 0 2px var(--accent); } |
| } |
| @media (max-width: 720px) { |
| /* Mobile: one pane, no split — chat/split → Chat, interface → Interface. */ |