From 6c484fa7f8db6a9a7db43442f3ea2d110cb9dd7e Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 8 Mar 2026 22:36:56 +0000 Subject: [PATCH] Changeset 0.25.2 (#162) --- VERSION | 2 +- server/pages/templates/base.html | 1 + server/pages/templates/surfaces/admin.html | 4 +- server/pages/templates/surfaces/settings.html | 4 +- src/css/chat.css | 5 +- src/css/layout.css | 8 - src/css/user-menu.css | 9 +- src/editor/wikilink.mjs | 6 +- src/js/drag-resize.js | 94 ++++++++++ src/js/notes.js | 26 ++- src/js/pane-container.js | 170 ++++++++---------- src/js/panels.js | 57 +++--- src/js/projects-ui.js | 5 + 13 files changed, 240 insertions(+), 151 deletions(-) create mode 100644 src/js/drag-resize.js diff --git a/VERSION b/VERSION index af2dabf..166c9e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.1 +0.25.2 diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index eec530a..1c74c01 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -101,6 +101,7 @@ + {{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}} diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html index 0920a6d..aea6325 100644 --- a/server/pages/templates/surfaces/admin.html +++ b/server/pages/templates/surfaces/admin.html @@ -9,9 +9,9 @@ {{/* Top Bar */}}
- + - Back to Chat + Back
Administration diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index c555949..5d7d683 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -9,9 +9,9 @@ {{/* Top Bar */}}
- + - Back to Chat + Back
diff --git a/src/css/chat.css b/src/css/chat.css index d1cb7ad..97249ea 100644 --- a/src/css/chat.css +++ b/src/css/chat.css @@ -171,16 +171,13 @@ .msg-inner { max-width: 768px; margin: 0 auto; display: flex; gap: 10px; } -/* User messages: right-aligned bubble */ -.message.user { display: flex; justify-content: flex-end; } +/* User messages: same fixed-width container, mirrored */ .message.user .msg-inner { flex-direction: row-reverse; } .message.user .msg-body { background: var(--user-bubble); border: 1px solid transparent; border-radius: 16px 16px 4px 16px; padding: 10px 14px; - flex: initial; - max-width: 85%; } .message.user .msg-head { flex-direction: row-reverse; } .message.user .msg-role { color: var(--accent); } diff --git a/src/css/layout.css b/src/css/layout.css index 7ff270d..731d4a9 100644 --- a/src/css/layout.css +++ b/src/css/layout.css @@ -112,14 +112,6 @@ .sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; } .sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; } .sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; } -.sidebar.collapsed .user-flyout { - position: fixed; - left: var(--sidebar-rail); - bottom: 8px; - right: auto; - width: 200px; - margin-bottom: 0; -} .sidebar.collapsed .avatar-bug { display: none; } /* Split button (New Chat + dropdown) */ diff --git a/src/css/user-menu.css b/src/css/user-menu.css index 190d43e..7689f8c 100644 --- a/src/css/user-menu.css +++ b/src/css/user-menu.css @@ -157,6 +157,11 @@ } .sidebar.collapsed .user-menu-wrap .user-flyout { - left: 8px; - right: 8px; + position: fixed; + left: var(--sidebar-rail); + bottom: 8px; + top: auto; + right: auto; + width: 200px; + margin-bottom: 0; } diff --git a/src/editor/wikilink.mjs b/src/editor/wikilink.mjs index 949c769..fd1d043 100644 --- a/src/editor/wikilink.mjs +++ b/src/editor/wikilink.mjs @@ -135,10 +135,12 @@ function wikilinkAutocomplete(linkCompleter) { options: results.map(r => ({ label: r.label || r.title, apply: (view, completion, from, to) => { - // Replace query text with completed title + closing ]] + // Check if ]] already exists after cursor (from closeBrackets auto-close) + const afterCursor = view.state.doc.sliceString(to, to + 2); + const endPos = afterCursor === ']]' ? to + 2 : to; const insert = completion.label + ']]'; view.dispatch({ - changes: { from, to, insert }, + changes: { from, to: endPos, insert }, selection: { anchor: from + insert.length }, }); }, diff --git a/src/js/drag-resize.js b/src/js/drag-resize.js new file mode 100644 index 0000000..0fda266 --- /dev/null +++ b/src/js/drag-resize.js @@ -0,0 +1,94 @@ +// ========================================== +// Chat Switchboard — Drag Resize Utility +// ========================================== +// v0.25.2: Single drag-resize primitive used by both the workspace +// handle (panels.js) and PaneContainer split handles (pane-container.js). +// +// Fixes vs the two prior implementations: +// - Full-viewport overlay during drag prevents iframes / CM6 from +// swallowing mousemove events (the "jump to middle" bug). +// - Touch support (touchstart / touchmove / touchend) everywhere. +// - Transitions on dragged elements are killed on grab and restored +// only after release — no mid-animation capture of wrong startW. +// +// Usage: +// initDragResize(handleEl, { +// direction: 'horizontal', // or 'vertical' +// onStart(clientPos) → ctx | false, // return drag context or false to cancel +// onMove(delta, ctx), // delta = px from start (signed) +// onEnd(ctx), // cleanup / persist +// }); + +/** + * Attach drag-resize behavior to a handle element. + * + * @param {HTMLElement} handleEl — the drag grip element + * @param {object} opts + * @param {'horizontal'|'vertical'} opts.direction + * @param {function} opts.onStart — receives initial clientX/Y, returns context or false + * @param {function} opts.onMove — receives (delta, ctx) + * @param {function} opts.onEnd — receives (ctx) + * @param {function} [opts.canDrag] — optional guard; return false to ignore mousedown + */ +function initDragResize(handleEl, opts) { + if (!handleEl) return; + + const isHoriz = (opts.direction || 'horizontal') === 'horizontal'; + + const onPointerDown = (e) => { + // Optional guard (e.g. "only when .active class is present") + if (opts.canDrag && !opts.canDrag()) return; + + e.preventDefault(); + const clientPos = _clientPos(e, isHoriz); + if (clientPos == null) return; + + const ctx = opts.onStart(clientPos); + if (ctx === false) return; + + // ── Overlay: blocks iframes / CodeMirror from eating events ── + const overlay = document.createElement('div'); + overlay.style.cssText = + 'position:fixed;inset:0;z-index:99999;' + + 'cursor:' + (isHoriz ? 'col-resize' : 'row-resize') + ';'; + document.body.appendChild(overlay); + document.body.style.cursor = isHoriz ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + const onMove = (e) => { + const pos = _clientPos(e, isHoriz); + if (pos == null) return; + opts.onMove(pos - clientPos, ctx); + }; + + const onUp = () => { + overlay.remove(); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + if (opts.onEnd) opts.onEnd(ctx); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + }; + + handleEl.addEventListener('mousedown', onPointerDown); + handleEl.addEventListener('touchstart', onPointerDown, { passive: false }); +} + +/** Extract the relevant coordinate from a mouse or touch event. */ +function _clientPos(e, isHoriz) { + if (e.touches?.length > 0) { + return isHoriz ? e.touches[0].clientX : e.touches[0].clientY; + } + if (e.changedTouches?.length > 0) { + return isHoriz ? e.changedTouches[0].clientX : e.changedTouches[0].clientY; + } + return isHoriz ? e.clientX : e.clientY; +} diff --git a/src/js/notes.js b/src/js/notes.js index 516cc8d..80eb981 100644 --- a/src/js/notes.js +++ b/src/js/notes.js @@ -19,8 +19,6 @@ async function openNotes() { } PanelRegistry.open('notes'); _exitSelectMode(); - await loadNotesList(); - await loadNoteFolders(); } async function loadNotesList(folder, searchQuery) { @@ -603,6 +601,10 @@ async function saveMessageToNote(msgIndex) { // Check for text selection within the message element const sel = window.getSelection(); let content = msg.content; + + // Strip thinking/reasoning blocks — only the response matters in notes + content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim(); + const msgId = msg.id || ''; const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null; if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) { @@ -820,7 +822,18 @@ function _registerNotesPanel() {
- `; + `; body.appendChild(el); // ── Wire listeners ── @@ -833,6 +846,9 @@ function _registerNotesPanel() { if (typeof openNoteGraph === 'function') openNoteGraph(); }); el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote); + el.querySelector('#notesGraphBackBtn').addEventListener('click', () => { + if (typeof closeNoteGraph === 'function') closeNoteGraph(); + }); el.querySelector('#notesSelectModeBtn').addEventListener('click', () => { if (_notesSelectMode) _exitSelectMode(); else _enterSelectMode(); @@ -890,7 +906,9 @@ function _registerNotesPanel() { element: el, label: 'Notes', onOpen() { - // Loading is handled by openNotes() which calls loadNotesList + // Ensure notes are loaded whenever the panel opens + loadNotesList(); + loadNoteFolders(); }, onClose() { _destroyNoteEditor(); diff --git a/src/js/pane-container.js b/src/js/pane-container.js index 45139a8..10efbf5 100644 --- a/src/js/pane-container.js +++ b/src/js/pane-container.js @@ -345,115 +345,99 @@ function _createHandle(direction, splitEl, index, surfaceId, splitId) { splitEl.appendChild(handle); } - // Drag logic - let startX, startY, leftEl, rightEl, startLeftBasis, startRightBasis; + // Track adjacent panes for dblclick reset + let lastLeftEl = null, lastRightEl = null; - const onMouseDown = (e) => { - e.preventDefault(); - const allPanes = Array.from(splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split')); - // Find the two panes adjacent to this handle - const handleIdx = Array.from(splitEl.children).indexOf(handle); - leftEl = splitEl.children[handleIdx - 1]; - rightEl = splitEl.children[handleIdx + 1]; - if (!leftEl || !rightEl) return; + initDragResize(handle, { + direction, + onStart(_clientPos) { + // Find the two panes adjacent to this handle + const handleIdx = Array.from(splitEl.children).indexOf(handle); + const leftEl = splitEl.children[handleIdx - 1]; + const rightEl = splitEl.children[handleIdx + 1]; + if (!leftEl || !rightEl) return false; - startX = e.clientX; - startY = e.clientY; - // Use current rendered size (matches what flex layout computed) - startLeftBasis = leftEl.getBoundingClientRect().width; - startRightBasis = rightEl.getBoundingClientRect().width; + lastLeftEl = leftEl; + lastRightEl = rightEl; - // Immediately lock the non-flex panes to their current sizes - // so the first move delta doesn't cause a visual snap - if (leftEl.dataset.paneFlex !== '1') { - leftEl.style.flexBasis = startLeftBasis + 'px'; - leftEl.style.flexGrow = '0'; - leftEl.style.flexShrink = '0'; - } - if (rightEl.dataset.paneFlex !== '1') { - rightEl.style.flexBasis = startRightBasis + 'px'; - rightEl.style.flexGrow = '0'; - rightEl.style.flexShrink = '0'; - } + const isHoriz = direction === 'horizontal'; + const dim = isHoriz ? 'width' : 'height'; + const startLeftBasis = leftEl.getBoundingClientRect()[dim]; + const startRightBasis = rightEl.getBoundingClientRect()[dim]; - handle.classList.add('pane-handle--active'); - document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize'; - document.body.style.userSelect = 'none'; + // Lock non-flex panes to current rendered size so the first + // move delta doesn't cause a visual snap + if (leftEl.dataset.paneFlex !== '1') { + leftEl.style.flexBasis = startLeftBasis + 'px'; + leftEl.style.flexGrow = '0'; + leftEl.style.flexShrink = '0'; + } + if (rightEl.dataset.paneFlex !== '1') { + rightEl.style.flexBasis = startRightBasis + 'px'; + rightEl.style.flexGrow = '0'; + rightEl.style.flexShrink = '0'; + } - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; + handle.classList.add('pane-handle--active'); - const onMouseMove = (e) => { - const delta = direction === 'horizontal' - ? e.clientX - startX - : e.clientY - startY; + return { leftEl, rightEl, startLeftBasis, startRightBasis }; + }, + onMove(delta, ctx) { + const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx; + const leftIsFlex = leftEl.dataset.paneFlex === '1'; + const rightIsFlex = rightEl.dataset.paneFlex === '1'; + const isHoriz = direction === 'horizontal'; + const dim = isHoriz ? 'width' : 'height'; + const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6; - const leftIsFlex = leftEl.dataset.paneFlex === '1'; - const rightIsFlex = rightEl.dataset.paneFlex === '1'; - // Cap: no single fixed pane should exceed 60% of container - const maxSize = splitEl.getBoundingClientRect().width * 0.6; - - if (leftIsFlex) { - // Only adjust the right (fixed) pane; left flex absorbs remainder - const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta)); - rightEl.style.flexBasis = newRight + 'px'; - rightEl.style.flexGrow = '0'; - rightEl.style.flexShrink = '0'; - } else if (rightIsFlex) { - // Only adjust the left (fixed) pane; right flex absorbs remainder - const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta)); - leftEl.style.flexBasis = newLeft + 'px'; - leftEl.style.flexGrow = '0'; - leftEl.style.flexShrink = '0'; - } else { - // Both fixed — adjust both (original behavior) - const newLeft = Math.max(50, startLeftBasis + delta); - const newRight = Math.max(50, startRightBasis - delta); - leftEl.style.flexBasis = newLeft + 'px'; - leftEl.style.flexGrow = '0'; - leftEl.style.flexShrink = '0'; - rightEl.style.flexBasis = newRight + 'px'; - rightEl.style.flexGrow = '0'; - rightEl.style.flexShrink = '0'; - } - }; - - const onMouseUp = () => { - handle.classList.remove('pane-handle--active'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - - // Persist sizes - _persistSizes(surfaceId, splitEl); - }; - - handle.addEventListener('mousedown', onMouseDown); + if (leftIsFlex) { + const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta)); + rightEl.style.flexBasis = newRight + 'px'; + rightEl.style.flexGrow = '0'; + rightEl.style.flexShrink = '0'; + } else if (rightIsFlex) { + const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta)); + leftEl.style.flexBasis = newLeft + 'px'; + leftEl.style.flexGrow = '0'; + leftEl.style.flexShrink = '0'; + } else { + const newLeft = Math.max(50, startLeftBasis + delta); + const newRight = Math.max(50, startRightBasis - delta); + leftEl.style.flexBasis = newLeft + 'px'; + leftEl.style.flexGrow = '0'; + leftEl.style.flexShrink = '0'; + rightEl.style.flexBasis = newRight + 'px'; + rightEl.style.flexGrow = '0'; + rightEl.style.flexShrink = '0'; + } + }, + onEnd(_ctx) { + handle.classList.remove('pane-handle--active'); + _persistSizes(surfaceId, splitEl); + }, + }); // Double-click resets to default handle.addEventListener('dblclick', () => { - if (leftEl) { - if (leftEl.dataset.paneFlex === '1') { - leftEl.style.flex = '1'; leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = ''; - } else { - leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = ''; - } - } - if (rightEl) { - if (rightEl.dataset.paneFlex === '1') { - rightEl.style.flex = '1'; rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = ''; - } else { - rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = ''; - } - } + _resetPaneSize(lastLeftEl); + _resetPaneSize(lastRightEl); _persistSizes(surfaceId, splitEl); }); return { handle, splitEl, index, direction }; } +/** Reset a pane element to its default flex sizing. */ +function _resetPaneSize(el) { + if (!el) return; + if (el.dataset.paneFlex === '1') { + el.style.flex = '1'; + } + el.style.flexBasis = ''; + el.style.flexGrow = ''; + el.style.flexShrink = ''; +} + // ── Persistence ───────────────────────────── function _persistSizes(surfaceId, splitEl) { diff --git a/src/js/panels.js b/src/js/panels.js index a89c2e2..c32d29a 100644 --- a/src/js/panels.js +++ b/src/js/panels.js @@ -302,46 +302,37 @@ function toggleSidePanelFullscreen() { // ── Workspace Resize Handle ──────────────── function _initWorkspaceResize() { - let startX, startW; const secondary = document.getElementById('workspaceSecondary'); const handle = document.getElementById('workspaceHandle'); if (!handle || !secondary) return; - const onDown = (e) => { - if (!handle.classList.contains('active')) return; - if (secondary.classList.contains('fullscreen')) return; - e.preventDefault(); - 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 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)); + initDragResize(handle, { + direction: 'horizontal', + canDrag() { + return handle.classList.contains('active') && + !secondary.classList.contains('fullscreen'); + }, + onStart(_clientPos) { + // Kill transition first, force reflow, then measure. + // This ensures startW reflects the final CSS value (not mid-animation), + // and the first onMove won't animate since transition is already dead. + // Do NOT set inline width here — getBoundingClientRect includes border + // but style.width is content-box, so it causes a 1px jump on click. + secondary.style.transition = 'none'; + void secondary.offsetWidth; + const startW = secondary.getBoundingClientRect().width; + return { startW }; + }, + onMove(delta, ctx) { + // Handle is left of secondary — dragging left (negative delta) grows it + const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.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 = ''; + }, + onEnd(_ctx) { secondary.style.transition = ''; - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.addEventListener('touchmove', onMove, { passive: false }); - document.addEventListener('touchend', onUp); - }; - - handle.addEventListener('mousedown', onDown); - handle.addEventListener('touchstart', onDown, { passive: false }); + }, + }); } // ── Mobile Swipe Navigation ───────────────── diff --git a/src/js/projects-ui.js b/src/js/projects-ui.js index 8d3fabb..c407fa8 100644 --- a/src/js/projects-ui.js +++ b/src/js/projects-ui.js @@ -153,6 +153,11 @@ async function deleteProject(projectId) { App.activeProjectId = null; localStorage.removeItem('cs-active-project'); } + // Close project panel if the deleted project was open in it + if (_projectPanelId === projectId && typeof PanelRegistry !== 'undefined') { + _projectPanelId = null; + PanelRegistry.close('project'); + } UI.renderChatList(); UI.toast('Project deleted', 'success'); } catch (e) {