Changeset 0.25.2 (#162)

This commit is contained in:
2026-03-08 22:36:56 +00:00
parent b3f8b747dd
commit 6c484fa7f8
13 changed files with 240 additions and 151 deletions

View File

@@ -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); }

View File

@@ -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) */

View File

@@ -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;
}

View File

@@ -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 },
});
},

94
src/js/drag-resize.js Normal file
View File

@@ -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;
}

View File

@@ -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() {
</div>
</div>
<!-- Graph view -->
<div id="notesGraphView" style="display:none;"></div>`;
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
<div class="notes-graph-toolbar">
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">← Back</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links
</span>
<div style="flex:1"></div>
<button class="btn-small" onclick="_graphToggleOrphans()" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" onclick="_graphResetZoom()" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
</div>`;
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();

View File

@@ -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) {

View File

@@ -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 ─────────────────

View File

@@ -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) {