fix(kb): RTL-correct pane order + new-result button + 30-min auto-clear

Three issues from the v0.2.0 user test:

1. Sides flipped relative to expectation
   In our Hebrew/RTL CRM the user reads primary content (the answer or
   the hit list) on the visual right and the supporting reference
   (the PDF) on the visual left — that's how Hebrew layouts stage
   "main + sidebar". v0.1.10's _buildSplitShell forced direction:ltr on
   the splitter container (correct, for mouseX math) but laid the
   children in the order [primary, handle, reference], which under LTR
   put primary on the LEFT and PDF on the RIGHT. Fixed by renaming the
   helper params to {primaryHtml, referenceHtml} and ordering the
   children as [reference, handle, primary] — visual LEFT is now the
   PDF, visual RIGHT is the answer/list. Default split moved from 42%
   to 58% so the reference pane (now on the left) keeps the same
   absolute width the PDF column had under v0.1.9's bootstrap rows.

2. No way to start a new query
   afterRender replays _lastSearch / _lastAsk on every remount, which
   means the prior result hangs around forever. Added a "חיפוש חדש" /
   "שאלה חדשה" button next to the submit button. clearResults() cancels
   any in-flight request (closes the EventSource for ask, drops
   _activeSearch reference for search), removes the cached entry from
   sessionStorage, empties the results pane, and refocuses the input.

3. Stale results from another session
   Cached results now auto-expire after 30 minutes — _loadJson checks
   completedAt against STALE_AFTER_MS and silently drops anything
   older. So coming back tomorrow the KB tab opens clean instead of
   showing yesterday's answer to whatever you asked then.

Refs Task Master #4, #6
This commit is contained in:
2026-04-25 13:16:30 +00:00
parent a00fc3729c
commit e80ccb8bdf
3 changed files with 75 additions and 14 deletions
@@ -28,6 +28,8 @@
<option value="circular">חוזרים</option>
</select>
<button type="button" class="btn btn-primary" data-action="submit">חפש</button>
<button type="button" class="btn btn-default" data-action="clearResults"
title="התחל חיפוש חדש (מנקה את התוצאות הקודמות)">חיפוש חדש</button>
</div>
<div class="small text-muted" style="margin-top:6px;">
חיפוש Hybrid: וקטורי + מילולי + rerank. מחזיר עד 8 קטעים רלוונטיים.
@@ -42,6 +44,8 @@
placeholder="שאלה בשפה חופשית…"
style="flex:1 1 320px;min-width:280px;" />
<button type="button" class="btn btn-primary" data-action="submit">שאל את שירה</button>
<button type="button" class="btn btn-default" data-action="clearResults"
title="התחל שאלה חדשה (מנקה את התשובה הקודמת)">שאלה חדשה</button>
</div>
<div class="small text-muted" style="margin-top:6px;">
שירה תחפש בבסיס הידע ותחזיר תשובה מסוכמת עם ציטוטים.
@@ -14,12 +14,21 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search';
const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session)
// Cached results auto-expire after this long. The user explicitly asked
// for a clear button, but auto-clearing also handles the case where they
// come back hours later and the prior result is no longer relevant.
const STALE_AFTER_MS = 30 * 60 * 1000;
function _loadJson(storage, key, validator) {
try {
const raw = storage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && parsed.completedAt &&
(Date.now() - parsed.completedAt) > STALE_AFTER_MS) {
storage.removeItem(key);
return null;
}
return validator(parsed) ? parsed : null;
} catch (e) { return null; }
}
@@ -78,6 +87,42 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this.openSource(id);
},
'click [data-action="clearResults"]': function (e) {
e.preventDefault();
this.clearResults();
},
},
// Cancel any in-flight ask/search, drop the cached last-result for
// the current mode, empty the results pane, and refocus the input.
// Bound to the "חיפוש חדש" / "שאלה חדשה" button as well as the
// 30-min auto-expiry path in afterRender.
clearResults: function () {
if (this.mode === 'search') {
if (_activeSearch) {
// Promise will still resolve in the background, but the
// .then handler short-circuits when _activeSearch !== self.
_activeSearch = null;
}
_lastSearch = null;
_clear(sessionStorage, SS_SEARCH);
} else if (this.mode === 'ask') {
if (_activeAsk) {
try { if (_activeAsk.es) _activeAsk.es.close(); }
catch (e) { /* already closed */ }
_activeAsk = null;
}
_lastAsk = null;
_clear(sessionStorage, SS_ASK);
this.stopAskProgress();
}
this.setLoading(false);
this.$el.find('.kb-results').empty();
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) {
$input.val('');
$input.trigger('focus');
}
},
setup: function () {
@@ -419,8 +464,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
}).join('');
$results.html(this._buildSplitShell({
leftHtml: `<div class="kb-search-list" style="height:100%;overflow-y:auto;padding-left:6px;">${listHtml}</div>`,
rightHtml: '<div class="kb-preview-slot" style="height:100%;"></div>',
primaryHtml: '<div class="kb-search-list" style="height:100%;overflow-y:auto;padding-right:6px;">' + listHtml + '</div>',
referenceHtml: '<div class="kb-preview-slot" style="height:100%;"></div>',
}));
this._wireSplitter($results.find('.kb-split'));
@@ -443,10 +488,19 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
},
// 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}) {
// 6px handle. In our Hebrew/RTL CRM users expect primary content
// (Shira's answer + sources, or the search hit list) on the visual
// RIGHT — matching how Hebrew reads — and the supporting reference
// (PDF iframe or section context) on the visual LEFT. We force
// direction:ltr on the container so the splitter's mouseX math
// doesn't get inverted by RTL inheritance; inner panes carry their
// own RTL direction as needed.
//
// Children render in DOM order [reference, handle, primary], which
// under direction:ltr maps to visual [LEFT, divider, RIGHT].
// pct is the LEFT (reference) pane's width %; default 58 matches
// the v0.1.9 col-md-7 PDF column.
_buildSplitShell: function ({primaryHtml, referenceHtml}) {
const pct = this._loadSplitPct();
return (
'<div class="kb-split" style="' +
@@ -454,7 +508,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
'gap:0;height:86vh;align-items:stretch;">' +
' <div class="kb-split-left" style="' +
'flex:0 0 ' + pct + '%;min-width:200px;overflow:hidden;">' +
leftHtml +
referenceHtml +
' </div>' +
' <div class="kb-split-handle" title="גרור כדי לשנות חלוקה" style="' +
'flex:0 0 6px;background:#dde1e7;cursor:col-resize;' +
@@ -462,7 +516,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
'transition:background 0.15s;"></div>' +
' <div class="kb-split-right" style="' +
'flex:1 1 auto;min-width:200px;overflow:hidden;">' +
rightHtml +
primaryHtml +
' </div>' +
'</div>'
);
@@ -473,7 +527,10 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const v = parseFloat(localStorage.getItem(LS_SPLIT));
if (!isNaN(v) && v >= 15 && v <= 85) return v;
} catch (e) { /* ignore */ }
return 42;
// Default = 58% reference (PDF) on left, ~42% primary on right.
// Matches the visual proportions of v0.1.9's col-md-7/col-md-5
// when the CRM rendered Bootstrap rows in RTL order.
return 58;
},
_saveSplitPct: function (pct) {
@@ -741,8 +798,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
+ encodeURIComponent(first.source_id)
+ '#page=' + encodeURIComponent(firstPage);
const leftHtml = `
<div style="height:100%;overflow-y:auto;padding-left:6px;">
const primaryHtml = `
<div style="height:100%;overflow-y:auto;padding-right:6px;">
<div class="kb-answer panel panel-info">
<div class="panel-body" style="direction:rtl;text-align:right;">
${answerHtml}
@@ -759,13 +816,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
</div>
</div>
`;
const rightHtml = `
const referenceHtml = `
<iframe class="kb-pdf-iframe"
src="${initialUrl}"
style="width:100%;height:100%;border:1px solid #ddd;background:#fafafa;"
title="מקור"></iframe>
`;
$results.html(this._buildSplitShell({leftHtml, rightHtml}));
$results.html(this._buildSplitShell({primaryHtml, referenceHtml}));
this._wireSplitter($results.find('.kb-split'));
const self = this;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "KnowledgeBase",
"module": "KnowledgeBase",
"version": "0.2.0",
"version": "0.2.1",
"acceptableVersions": [
">=8.0.0"
],