From ac5823ffe5565ef3e2d73aaf3a2dbe7150c3a7d3 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 25 Apr 2026 10:46:39 +0000 Subject: [PATCH] feat(kb): drag-to-resize splitter + search survives view switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two requests on top of v0.1.9: 1. Drag-to-resize splitter The previous fixed Bootstrap col-md-5/col-md-7 split locked the user into 41.66% answer / 58.33% PDF. Replaced with a flex-row layout containing a 6px drag handle. mousedown spawns a fixed transparent overlay so the PDF.js iframe doesn't swallow mouse events while dragging; mouseup persists the chosen ratio (clamped 15-85%) to localStorage as kb-split-pct, so the next page-load and the next session both come back to the same layout. Both the ask split view and the search split view share _buildSplitShell + _wireSplitter — single source of truth for the geometry. 2. Search survives a view switch Search had the same view-bound promise problem ask had before v0.1.9. Hoisted the in-flight request to a module-level _activeSearch and the most recent {query, kind, hits, selectedIdx} to _lastSearch + sessionStorage. afterRender restores the input, the kind filter, the hit list (with the same hit highlighted), and the right-pane preview. Click handler updates selectedIdx so a round-trip lands on the user's last-clicked hit, not always on the top-ranked one. The persistence helpers grew a tiny refactor: _loadJson/_saveJson/_clear replace the ask-only SS_KEY shim and now back both ask + search keys. Refs Task Master #5, #6 --- .taskmaster/tasks/tasks.json | 24 ++ .../knowledge-base/src/views/kb/index.js | 271 +++++++++++++----- manifest.json | 2 +- 3 files changed, 226 insertions(+), 71 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 4c8aab5..abb3228 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -37,6 +37,30 @@ "dependencies": [], "createdAt": "2026-04-25T10:00:00Z" }, + { + "id": 5, + "title": "feat(kb): drag-to-resize splitter (both ask + search)", + "description": "Replace the fixed Bootstrap col-md-5/7 split with a flex layout containing a 6px drag handle. mousedown on the handle starts the drag; while dragging an invisible full-screen overlay covers any iframe so the PDF.js viewer doesn't swallow mouse events. The chosen ratio (15-85%) persists to localStorage as kb-split-pct so the layout sticks across sessions. Both modes share the same _buildSplitShell helper so the look stays consistent.", + "status": "done", + "priority": "normal", + "details": "Default ratio = 42% left (matches the previous col-md-5 ≈ 41.66%). Applied to renderSearchResults, renderAskAnswer, and showSearchPreview (which now uses 100%-height containers via flex column).", + "testStrategy": "Open ask mode, drag the divider; verify PDF iframe doesn't lose mouse focus mid-drag; reload — divider stays at last position. Repeat in search mode.", + "subtasks": [], + "dependencies": [], + "createdAt": "2026-04-25T11:00:00Z" + }, + { + "id": 6, + "title": "fix: search survives view switch (parity with ask in v0.1.9)", + "description": "Search mode had the same view-bound promise problem as ask did before v0.1.9: navigating to another EspoCRM screen and back blanked the results. Hoisted {query, kind, promise} to a module-level _activeSearch with the same {query, kind, hits, selectedIdx} sessionStorage replay we already built for ask. Selected hit index also persists so the right pane comes back to the same source/page the user had open.", + "status": "done", + "priority": "high", + "details": "Mirrors the v0.1.9 ask fix in _attachSearchHandlers + afterRender. Click handler updates _lastSearch.selectedIdx so a return-trip lands on the user's last-clicked hit, not always the top-ranked one.", + "testStrategy": "Search 'מהי תקנה 37'; click a result other than the first; navigate to Contacts; navigate back — same hit selected, same PDF page on the right.", + "subtasks": [], + "dependencies": [], + "createdAt": "2026-04-25T11:00:00Z" + }, { "id": 4, "title": "fix: ask survives view switch via module-level promise + sessionStorage", diff --git a/files/client/custom/modules/knowledge-base/src/views/kb/index.js b/files/client/custom/modules/knowledge-base/src/views/kb/index.js index 28478ac..1f48c6e 100644 --- a/files/client/custom/modules/knowledge-base/src/views/kb/index.js +++ b/files/client/custom/modules/knowledge-base/src/views/kb/index.js @@ -6,28 +6,36 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { // it. sessionStorage backs the last completed answer so a hard reload // also recovers it. None of this leaks to the next browser session — // ask history is intentionally ephemeral. - let _activeAsk = null; // {question, promise} while a request is in flight - let _lastAsk = null; // {question, text, sources, completedAt} after success + let _activeAsk = null; // {question, promise, startedAt} while in flight + let _lastAsk = null; // {question, text, sources, completedAt} after success + let _activeSearch = null; // {query, kind, promise} while in flight + let _lastSearch = null; // {query, kind, hits, selectedIdx, completedAt} - const SS_KEY = 'kb-last-ask'; + const SS_ASK = 'kb-last-ask'; + const SS_SEARCH = 'kb-last-search'; + const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session) - function _loadLastFromSession() { + function _loadJson(storage, key, validator) { try { - const raw = sessionStorage.getItem(SS_KEY); + const raw = storage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); - if (parsed && typeof parsed.question === 'string') return parsed; - } catch (e) { /* corrupted, ignore */ } - return null; + return validator(parsed) ? parsed : null; + } catch (e) { return null; } } - function _saveLastToSession(entry) { - try { sessionStorage.setItem(SS_KEY, JSON.stringify(entry)); } + function _saveJson(storage, key, value) { + try { storage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota or disabled, fine */ } } - function _clearLastInSession() { - try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* ignore */ } + function _clear(storage, key) { + try { storage.removeItem(key); } catch (e) { /* ignore */ } + } + if (!_lastAsk) { + _lastAsk = _loadJson(sessionStorage, SS_ASK, x => x && typeof x.question === 'string'); + } + if (!_lastSearch) { + _lastSearch = _loadJson(sessionStorage, SS_SEARCH, x => x && typeof x.query === 'string' && Array.isArray(x.hits)); } - if (!_lastAsk) _lastAsk = _loadLastFromSession(); return Dep.extend({ @@ -98,10 +106,10 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { this.renderSourcesList(); } - // Recover ask state across view remounts (user navigated away and - // back). _activeAsk wins — there's still a request in flight, so - // re-show the progress panel and re-bind handlers. Otherwise - // _lastAsk replays the most recent answer in this browser session. + // Recover prior state across view remounts (user navigated away and + // back). Active in-flight wins; otherwise replay the last result + // from sessionStorage so the work the user already did doesn't + // disappear behind a tab switch. if (this.mode === 'ask') { if (_activeAsk) { const $input = this.$el.find('input[data-name="query"]'); @@ -114,6 +122,19 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { if ($input.length) $input.val(_lastAsk.question); this.renderAskAnswer(_lastAsk.text, _lastAsk.sources); } + } else if (this.mode === 'search') { + if (_activeSearch) { + const $input = this.$el.find('input[data-name="query"]'); + if ($input.length) $input.val(_activeSearch.query); + this.setLoading(true); + this._attachSearchHandlers(_activeSearch); + } else if (_lastSearch && _lastSearch.hits.length) { + const $input = this.$el.find('input[data-name="query"]'); + if ($input.length) $input.val(_lastSearch.query); + const $kind = this.$el.find('select[data-name="kind"]'); + if ($kind.length && _lastSearch.kind) $kind.val(_lastSearch.kind); + this.renderSearchResults(_lastSearch.hits, _lastSearch.selectedIdx || 0); + } } // Focus the input on every render so users can start typing right away. @@ -135,24 +156,53 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { }, runSearch: function (query, kind) { + // A new search invalidates the prior cached one. + _lastSearch = null; + _clear(sessionStorage, SS_SEARCH); + this.setLoading(true); - Espo.Ajax.postRequest('KnowledgeBase/action/search', { + const promise = Espo.Ajax.postRequest('KnowledgeBase/action/search', { query: query, kind: kind, topK: 8, - }).then(res => { - this.setLoading(false); - this.renderSearchResults(res.hits || []); + }); + _activeSearch = {query: query, kind: kind, promise: promise}; + this._attachSearchHandlers(_activeSearch); + }, + + _attachSearchHandlers: function (search) { + const self = this; + search.promise.then(res => { + const hits = res.hits || []; + const entry = { + query: search.query, + kind: search.kind, + hits: hits, + selectedIdx: 0, + completedAt: Date.now(), + }; + _lastSearch = entry; + _saveJson(sessionStorage, SS_SEARCH, entry); + if (_activeSearch === search) _activeSearch = null; + if (self.$el && self.$el.length) { + self.setLoading(false); + if (self.mode === 'search') { + self.renderSearchResults(hits); + } + } }).catch(err => { - this.setLoading(false); - this.showError(err); + if (_activeSearch === search) _activeSearch = null; + if (self.$el && self.$el.length) { + self.setLoading(false); + self.showError(err); + } }); }, runAsk: function (message) { // A new ask invalidates whatever last-result was on screen. _lastAsk = null; - _clearLastInSession(); + _clear(sessionStorage, SS_ASK); this.setLoading(true); // Ask goes through the full agent loop (search + rerank + LLM), @@ -181,7 +231,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { completedAt: Date.now(), }; _lastAsk = entry; - _saveLastToSession(entry); + _saveJson(sessionStorage, SS_ASK, entry); if (_activeAsk === ask) _activeAsk = null; if (self.$el && self.$el.length) { self.stopAskProgress(); @@ -249,12 +299,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { } }, - renderSearchResults: function (hits) { + renderSearchResults: function (hits, selectedIdx) { const $results = this.$el.find('.kb-results'); if (!hits.length) { $results.html('
לא נמצאו תוצאות.
'); return; } + const initialIdx = (typeof selectedIdx === 'number' && hits[selectedIdx]) ? selectedIdx : 0; const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'}; const self = this; @@ -272,7 +323,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { const pub = h.published_at ? ' · פורסם ' + h.published_at : ''; const pageLabel = (isPdfBacked(h) && h.page_number) ? ` · עמ׳ ${h.page_number}` : ''; - const activeClass = i === 0 ? 'panel-primary' : 'panel-default'; + const activeClass = i === initialIdx ? 'panel-primary' : 'panel-default'; return `
@@ -285,20 +336,15 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
`; }).join(''); - $results.html(` -
-
- ${listHtml} -
-
-
-
-
- `); + $results.html(this._buildSplitShell({ + leftHtml: `
${listHtml}
`, + rightHtml: '
', + })); + this._wireSplitter($results.find('.kb-split')); // Cache hits on the view so click handlers can reach them. this._searchHits = hits; - this.showSearchPreview(0); + this.showSearchPreview(initialIdx); this.$el.find('.kb-hit').off('click').on('click', function () { const idx = parseInt($(this).data('hit-idx'), 10); @@ -307,6 +353,90 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { .removeClass('panel-primary').addClass('panel-default'); $(this).removeClass('panel-default').addClass('panel-primary'); self.showSearchPreview(idx); + if (_lastSearch && _lastSearch.hits === self._searchHits) { + _lastSearch.selectedIdx = idx; + _saveJson(sessionStorage, SS_SEARCH, _lastSearch); + } + }); + }, + + // Returns the HTML for a flex-row split layout with a draggable + // 6px handle. The container's flex direction is forced LTR so the + // splitter math (clientX) doesn't get inverted by RTL — content + // inside each pane keeps its own direction. + _buildSplitShell: function ({leftHtml, rightHtml}) { + const pct = this._loadSplitPct(); + return ( + '
' + + '
' + + leftHtml + + '
' + + '
' + + '
' + + rightHtml + + '
' + + '
' + ); + }, + + _loadSplitPct: function () { + try { + const v = parseFloat(localStorage.getItem(LS_SPLIT)); + if (!isNaN(v) && v >= 15 && v <= 85) return v; + } catch (e) { /* ignore */ } + return 42; + }, + + _saveSplitPct: function (pct) { + try { localStorage.setItem(LS_SPLIT, String(pct)); } + catch (e) { /* ignore */ } + }, + + // Wires mousedown/move/up on the handle to resize the left pane. + // While dragging we cover any iframe with a transparent overlay so + // mouse events don't disappear into PDF.js. On mouseup the chosen + // ratio persists to localStorage. + _wireSplitter: function ($split) { + if (!$split.length) return; + const $handle = $split.find('.kb-split-handle'); + const $left = $split.find('.kb-split-left'); + const self = this; + $handle.on('mouseenter', () => $handle.css('background', '#b9c0cb')); + $handle.on('mouseleave', () => $handle.css('background', '#dde1e7')); + $handle.on('mousedown', function (e) { + e.preventDefault(); + const rect = $split[0].getBoundingClientRect(); + const $overlay = $('
').css({ + position: 'fixed', + inset: '0', + cursor: 'col-resize', + zIndex: 9999, + background: 'transparent', + }); + $('body').append($overlay).css('user-select', 'none'); + const onMove = function (ev) { + const x = ev.clientX - rect.left; + let pct = (x / rect.width) * 100; + pct = Math.max(15, Math.min(85, pct)); + $left.css('flex', '0 0 ' + pct + '%'); + self._dragPct = pct; + }; + const onUp = function () { + $(document).off('mousemove', onMove).off('mouseup', onUp); + $overlay.remove(); + $('body').css('user-select', ''); + if (typeof self._dragPct === 'number') { + self._saveSplitPct(self._dragPct); + } + }; + $(document).on('mousemove', onMove).on('mouseup', onUp); }); }, @@ -323,13 +453,14 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { + encodeURIComponent(h.source_id) + '#page=' + encodeURIComponent(page); $slot.html(` -
- מציג עמ׳ ${page} של המסמך +
+
+ מציג עמ׳ ${page} של המסמך +
+
- `); return; } @@ -343,12 +474,12 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { ? `מקור ב-Wikisource` : ''; $slot.html(` -
-
${this.escape(h.title || '')}
-
+
+
${this.escape(h.title || '')}
+
${this.escape(h.content || '')}
- ${srcLink ? '' : ''} + ${srcLink ? '' : ''}
`); }, @@ -437,32 +568,32 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { + encodeURIComponent(first.source_id) + '#page=' + encodeURIComponent(firstPage); - $results.html(` -
-
-
-
- ${answerHtml} -
-
-
-
📎 מקורות
-
-
${pickerItems}
- -
+ const leftHtml = ` +
+
+
+ ${answerHtml}
-
- +
+
📎 מקורות
+
+
${pickerItems}
+ +
- `); + `; + const rightHtml = ` + + `; + $results.html(this._buildSplitShell({leftHtml, rightHtml})); + this._wireSplitter($results.find('.kb-split')); const self = this; this.$el.find('.kb-pdf-pick').off('click').on('click', function () { diff --git a/manifest.json b/manifest.json index 950749f..bffa9db 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "KnowledgeBase", "module": "KnowledgeBase", - "version": "0.1.9", + "version": "0.1.10", "acceptableVersions": [ ">=8.0.0" ],