diff --git a/README.md b/README.md
index 2f77e85..dff4528 100644
--- a/README.md
+++ b/README.md
@@ -203,7 +203,9 @@ The Trace tab's side panel has a view selector (*Map* / *Stats* / **Files**;
a drop-down, since three tab captions no longer fit). The Files view is a
trace-wide timeline of file operations — every **Read**, **Update** and
**Changed on card** row shows the same file name as the Map view, and clicking
-a row jumps to the APDU in the trace. Rows that carry a recorded change (a
+a row jumps straight to the APDU in the trace (the APDU list keeps the
+reader's position: new messages pin the view to the bottom only while the
+reader is already there, so a jump is never pulled away by the live poll). Rows that carry a recorded change (a
card-side change, or an update that wrote or first observed bytes) also offer
**Details**: a modal showing the full file path (`MF/EF_ICCID (2FE2)`,
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF
diff --git a/README_RUS.md b/README_RUS.md
index 3b3308d..7fb401c 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -207,7 +207,9 @@ Application Layer* — два отдельных поля выбора, имен
**Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид
Files — это таймлайн файловых операций по всей трассе: строки **Read**,
**Update** и **Changed on card** показывают те же имена файлов, что и вид Map,
-а щелчок по строке переходит к APDU в трассе. У строк с записанным изменением
+а щелчок по строке переходит прямо к APDU в трассе (список APDU сохраняет
+позицию читателя: новые сообщения прижимают вид к низу, только пока читатель
+уже внизу, поэтому переход не срывается живым опросом). У строк с записанным изменением
(изменение со стороны карты либо запись, изменившая или впервые показавшая
байты) есть кнопка **Details**: окно показывает полный путь файла (`MF/EF_ICCID (2FE2)`,
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF
diff --git a/frontend/index.html b/frontend/index.html
index 58fe7e0..1e38013 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2290,7 +2290,9 @@ async function jumpToMessage(idx) {
if (!data.messages || !data.messages.length) break;
STATE.messages.push(...data.messages);
}
- renderMessages();
+ // Render without the stick-to-bottom: focusMessage() scrolls to the
+ // record (the render only materializes its element).
+ renderMessages(true);
focusMessage(idx);
} catch (e) { /* keep the current view */ }
}
@@ -2395,8 +2397,21 @@ function renderTraceEmptyState() {
updateStatus();
}
-function renderMessages(skipScroll) {
+// Content-follow decision for the APDU list: pin the view to the newest
+// messages only when the reader is already at the bottom. An explicit jump
+// (*skipScroll*) never sticks, and a freshly loaded session/capture
+// (*forceStick*) always starts at the bottom.
+function shouldStickToBottom(list, skipScroll, forceStick) {
+ if (forceStick) return true;
+ if (skipScroll) return false;
+ return list.scrollTop + list.clientHeight >= list.scrollHeight - 40;
+}
+
+function renderMessages(skipScroll, forceStick) {
const list = el('apdu-list');
+ // Read the geometry before the DOM is touched: adding messages grows
+ // scrollHeight, which would hide the fact that the reader was at the end.
+ const stick = shouldStickToBottom(list, skipScroll, forceStick);
const searchQ = el('search-input').value.toLowerCase();
const filterType = el('filter-select').value;
@@ -2449,7 +2464,7 @@ function renderMessages(skipScroll) {
`;
}).join('');
- if (!skipScroll) list.scrollTop = list.scrollHeight;
+ if (stick) list.scrollTop = list.scrollHeight;
updateStatus();
}
@@ -2552,7 +2567,7 @@ async function startCapture() {
buildTypeCounts();
buildTraceMap();
renderSidePanel();
- renderMessages();
+ renderMessages(false, true);
el('apdu-detail').innerHTML = `
${t('Click an APDU to see details')}
`;
updateCaptureButton();
startPolling();
@@ -2937,7 +2952,7 @@ async function loadSession(id) {
STATE.mapExpanded.clear();
switchTab('trace');
updateCaptureButton();
- renderMessages();
+ renderMessages(false, true);
el('apdu-detail').innerHTML = `${t('Click an APDU to see details')}
`;
el('session-name-display').style.display = 'inline';
el('session-name-display').textContent = t('Session: ') + (data.session.name || '#' + id);
@@ -3191,7 +3206,7 @@ function onFilterChange() {
}
// ──────────────────── Versioning ────────────────────
-const PWA_VERSION = '2.6.0';
+const PWA_VERSION = '2.6.1';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
diff --git a/frontend/sw.js b/frontend/sw.js
index 0a0396a..5bfa91e 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v72';
+const CACHE = 'simtrace-analyser-v73';
const PRECACHE = [
'index.html',
diff --git a/frontend/tests/files_jump.test.js b/frontend/tests/files_jump.test.js
new file mode 100644
index 0000000..7dcadf1
--- /dev/null
+++ b/frontend/tests/files_jump.test.js
@@ -0,0 +1,83 @@
+// The Files-view row jump and the APDU list's content-follow decision:
+// clicking a timeline row must scroll straight to its APDU — it used to
+// render the list with the stick-to-bottom (jump to the end of the trace)
+// and then smooth-scroll back up to the record.
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { loadSrc, extractFunc } = require('./helpers.js');
+
+const src = loadSrc();
+
+let STATE = {};
+let API = {};
+let renders = [];
+let focused = [];
+function renderMessages(skipScroll, forceStick) {
+ renders.push([skipScroll, forceStick]);
+}
+function focusMessage(idx) {
+ focused.push(idx);
+}
+eval(
+ extractFunc(src, 'jumpToMessage') + '\n' +
+ extractFunc(src, 'shouldStickToBottom')
+);
+
+test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
+ renders = [];
+ focused = [];
+ STATE = { sessionViewId: 7, messages: [{}, {}] };
+ API = {
+ getSession: async (sid, from) => ({
+ messages: from === 2 ? [{}, {}, {}] : [],
+ }),
+ };
+ await jumpToMessage(3);
+ assert.equal(STATE.messages.length, 5);
+ assert.deepEqual(renders, [[true, undefined]]);
+ assert.deepEqual(focused, [3]);
+});
+
+test('jumpToMessage no-ops without a session view', async () => {
+ renders = [];
+ focused = [];
+ STATE = { sessionViewId: null, messages: [] };
+ API = { getSession: async () => { throw new Error('must not fetch'); } };
+ await jumpToMessage(3);
+ assert.deepEqual(renders, []);
+ assert.deepEqual(focused, []);
+});
+
+test('shouldStickToBottom: only a reader at the bottom follows new content', () => {
+ const list = (scrollTop, clientHeight, scrollHeight) => ({ scrollTop, clientHeight, scrollHeight });
+ // At the bottom / within the slack → follow.
+ assert.equal(shouldStickToBottom(list(500, 100, 600), false, false), true);
+ assert.equal(shouldStickToBottom(list(560, 100, 600), false, false), true);
+ // Scrolled up (e.g. after a Files/map jump) → leave the view alone.
+ assert.equal(shouldStickToBottom(list(200, 100, 600), false, false), false);
+ // An explicit jump never sticks; a new session/capture always does.
+ assert.equal(shouldStickToBottom(list(200, 100, 600), true, false), false);
+ assert.equal(shouldStickToBottom(list(200, 100, 600), false, true), true);
+ // First render: the empty list has no geometry yet.
+ assert.equal(shouldStickToBottom(list(0, 0, 0), false, false), true);
+});
+
+test('renderMessages scrolls through shouldStickToBottom', () => {
+ const body = extractFunc(src, 'renderMessages');
+ assert.match(body, /function renderMessages\(skipScroll, forceStick\)/);
+ assert.match(body, /const stick = shouldStickToBottom\(list, skipScroll, forceStick\);/);
+ assert.match(body, /if \(stick\) list\.scrollTop = list\.scrollHeight;/);
+ assert.doesNotMatch(body, /if \(!skipScroll\) list\.scrollTop/);
+});
+
+test('jumpToMessage renders with skipScroll (source pin)', () => {
+ const body = extractFunc(src, 'jumpToMessage');
+ assert.match(body, /renderMessages\(true\);/);
+ assert.match(body, /focusMessage\(idx\);/);
+});
+
+test('a new session/capture lands at the bottom (source pins)', () => {
+ assert.match(extractFunc(src, 'loadSession'), /renderMessages\(false, true\);/);
+ assert.match(extractFunc(src, 'startCapture'), /renderMessages\(false, true\);/);
+});
diff --git a/pyproject.toml b/pyproject.toml
index 5789ae6..64c12d9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace-analyser"
-version = "2.6.0"
+version = "2.6.1"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"}
diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py
index 6c5b69c..8815113 100644
--- a/simtrace_analyser/version.py
+++ b/simtrace_analyser/version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version."""
-__version__ = '2.6.0'
+__version__ = '2.6.1'
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 126085a..875a48a 100644
--- a/tests/test_pcap_import.py
+++ b/tests/test_pcap_import.py
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
- self.assertEqual(__version__, '2.6.0')
+ self.assertEqual(__version__, '2.6.1')
def test_status_includes_version(self):
import os