eleven / ocho Public

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>
James Adam fe82e20f21bbf070598dd6905616bce363662a6e parent 28f5e04
3 files changed, +95 −0
  • web/pages/index.html +1 −0
  • web/static/shell.js +64 −0
  • web/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. */