Release v0.51.269 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl) (#3672)
Some checks failed
Release & Docker / release (push) Has been cancelled

* perf(ui): single-pass sidebar session row partitioning (#3658)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* fix(search): scope session search to active profile (#3646)

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>

* fix(ctl): tree-kill ctl.sh stop on Windows (#3670)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.269 — Release IK (stage-b2)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes
2026-06-05 10:53:45 -07:00
committed by GitHub
parent 442b033e67
commit 2c7b530071
11 changed files with 373 additions and 81 deletions

View File

@@ -3,6 +3,15 @@
## [Unreleased]
## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
### Fixed
- **Session search results are now scoped to the active profile** (matching how the sidebar already filters), so a search no longer surfaces sessions belonging to other profiles unless `?all_profiles=1` is requested. (#3646, @Hinotoi-agent)
- **`ctl.sh stop` now tree-kills the WebUI process on Windows (Git Bash/MSYS)** via `taskkill //F //T`, and resolves owned-process paths across Windows/POSIX path forms. POSIX behavior is unchanged (falls through to the existing `kill`/`kill -KILL`). (#3670, @rodboev)
### Performance
- **The sidebar session list is partitioned in a single pass** instead of chaining five separate `.filter()` passes over the row set on every render, reducing per-render work for large session lists. Filtering/ordering/archived-count behavior is unchanged. (#3658, @pamnard)
## [v0.51.268] — 2026-06-05 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes)
### Fixed

View File

@@ -8370,6 +8370,15 @@ def _handle_sessions_search(handler, parsed):
qs = parse_qs(parsed.query)
q = qs.get("q", [""])[0].lower().strip()
content_search = qs.get("content", ["1"])[0] == "1"
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
all_profiles = _all_profiles_query_flag(parsed)
sessions = all_sessions()
if not all_profiles:
sessions = [
s for s in sessions
if _profiles_match(s.get("profile"), active_profile)
]
# Reject a malformed depth instead of letting int() raise ValueError and
# surface as a confusing 500. Clamp to >= 0 so a negative value can't reach
# the messages[:depth] slice below — messages[:-n] would silently exclude
@@ -8381,14 +8390,18 @@ def _handle_sessions_search(handler, parsed):
depth = 5
if not q:
safe_sessions = []
for s in all_sessions():
for s in sessions:
item = dict(s)
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
safe_sessions.append(item)
return j(handler, {"sessions": safe_sessions})
return j(handler, {
"sessions": safe_sessions,
"all_profiles": all_profiles,
"active_profile": active_profile,
})
results = []
for s in all_sessions():
for s in sessions:
title_match = q in (s.get("title") or "").lower()
if title_match:
item = dict(s, match_type="title")
@@ -8413,7 +8426,13 @@ def _handle_sessions_search(handler, parsed):
break
except (KeyError, Exception):
pass
return j(handler, {"sessions": results, "query": q, "count": len(results)})
return j(handler, {
"sessions": results,
"query": q,
"count": len(results),
"all_profiles": all_profiles,
"active_profile": active_profile,
})
def _handle_list_dir(handler, parsed):

87
ctl.sh
View File

@@ -165,21 +165,96 @@ _is_alive() {
kill -0 "${pid}" >/dev/null 2>&1
}
_proc_args() {
_is_windows_bash() {
[[ "${OS:-}" == "Windows_NT" ]] && return 0
case "$(uname -s 2>/dev/null || true)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
_windows_bash_path() {
local path="${1//\\//}" drive rest
if [[ "${path}" =~ ^([A-Za-z]):(.*)$ ]]; then
drive="${BASH_REMATCH[1],,}"
rest="${BASH_REMATCH[2]}"
printf '/%s%s\n' "${drive}" "${rest}"
return
fi
printf '%s\n' "${path}"
}
_windows_pid_for_bash_pid() {
local pid="$1"
ps -p "${pid}" -o args= 2>/dev/null || true
ps -p "${pid}" -l 2>/dev/null | awk 'NR == 2 { print $4 }'
}
_stop_webui_pid() {
local pid="$1" signal="${2:-TERM}"
if _is_windows_bash && command -v taskkill >/dev/null 2>&1; then
local winpid
winpid="$(_windows_pid_for_bash_pid "${pid}")"
if [[ "${winpid}" =~ ^[0-9]+$ ]]; then
taskkill //F //T //PID "${winpid}" >/dev/null 2>&1 || true
return
fi
fi
if [[ "${signal}" == "KILL" ]]; then
kill -KILL "${pid}" >/dev/null 2>&1 || true
else
kill "${pid}" >/dev/null 2>&1 || true
fi
}
_proc_args() {
local pid="$1" args
args="$(ps -p "${pid}" -o args= 2>/dev/null || true)"
if [[ -n "${args}" ]]; then
printf '%s\n' "${args}"
return
fi
if _is_windows_bash; then
local winpid
winpid="$(_windows_pid_for_bash_pid "${pid}")"
if [[ "${winpid}" =~ ^[0-9]+$ ]] && command -v wmic >/dev/null 2>&1; then
args="$(wmic process where "ProcessId=${winpid}" get CommandLine //value 2>/dev/null | sed -n 's/^CommandLine=//p' | tr -d '\r')"
if [[ -n "${args}" ]]; then
printf '%s\n' "${args}"
return
fi
fi
ps -p "${pid}" -f 2>/dev/null | awk 'NR == 2 { for (i = 8; i <= NF; i++) printf "%s%s", (i == 8 ? "" : " "), $i; print "" }'
fi
}
_is_owned_webui_pid() {
local pid="$1" args state_repo="" state_python=""
local pid="$1" args args_slash state_repo="" state_repo_slash="" state_repo_win="" state_repo_win_slash="" state_python="" state_python_slash="" state_python_bash=""
[[ -f "${STATE_FILE}" ]] || return 1
_load_state_if_present
state_repo="${REPO_ROOT:-}"
state_python="${PYTHON_EXE:-}"
state_repo_slash="${state_repo//\\//}"
state_python_slash="${state_python//\\//}"
if _is_windows_bash; then
state_repo_win="$(cygpath -w "${state_repo}" 2>/dev/null || true)"
state_repo_win_slash="${state_repo_win//\\//}"
fi
if [[ -n "${state_python}" ]] && _is_windows_bash; then
state_python_bash="$(_windows_bash_path "${state_python}")"
fi
[[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1
args="$(_proc_args "${pid}")"
[[ -n "${args}" ]] || return 1
[[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]]
args_slash="${args//\\//}"
[[ "${args_slash}" == *"${state_repo_slash}/bootstrap.py"* ||
"${args_slash}" == *"${state_repo_slash}/server.py"* ||
"${args_slash}" == *"${state_repo_slash}/start.sh"* ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/bootstrap.py"* ) ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/server.py"* ) ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/start.sh"* ) ||
( -n "${state_python}" && "${args}" == *"${state_python}"* ) ||
( -n "${state_python_slash}" && "${args_slash}" == *"${state_python_slash}"* ) ||
( -n "${state_python_bash}" && "${args_slash}" == *"${state_python_bash}"* ) ]]
}
_current_pid() {
@@ -308,7 +383,7 @@ stop_cmd() {
fi
echo "[ctl] Stopping Hermes WebUI (PID ${pid})"
kill "${pid}" >/dev/null 2>&1 || true
_stop_webui_pid "${pid}" TERM
local i
for i in {1..50}; do
if ! _is_alive "${pid}"; then
@@ -320,7 +395,7 @@ stop_cmd() {
done
echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >&2
kill -KILL "${pid}" >/dev/null 2>&1 || true
_stop_webui_pid "${pid}" KILL
rm -f "${PID_FILE}" "${STATE_FILE}"
}

View File

@@ -4093,6 +4093,55 @@ function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtual
});
}
function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
return (s.message_count||0)>0 ||
_sessionAttentionState(s) ||
_isSessionEffectivelyStreaming(s) ||
!!s.active_stream_id ||
!!s.pending_user_message ||
(activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
}
function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
let webuiSessionCount=0;
let cliSessionCount=0;
for(const s of allMatched){
if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue;
if(_isCliSession(s)) cliSessionCount++;
else webuiSessionCount++;
}
if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){
_sessionSourceFilter='webui';
}
const showCliOnly=_sessionSourceFilter==='cli';
const profileFiltered=[];
const sessionsRaw=[];
let archivedCount=0;
for(const s of allMatched){
if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue;
const isCli=_isCliSession(s);
if(showCliOnly ? !isCli : isCli) continue;
if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;
profileFiltered.push(s);
if(_activeProject===NO_PROJECT_FILTER){
if(s.project_id) continue;
} else if(_activeProject){
if(s.project_id!==_activeProject) continue;
}
if(s.archived) archivedCount++;
if(!_showArchived&&s.archived) continue;
sessionsRaw.push(s);
}
return {
webuiSessionCount,
cliSessionCount,
profileFiltered,
sessionsRaw,
archivedCount,
};
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
@@ -4115,49 +4164,15 @@ function renderSessionListFromCache(){
// session id into another conversation, that content hit should still appear.
const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);
const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);
// Keep inactive ephemeral 0-message sessions out of the sidebar — they only
// become real once the first message is sent. The server already filters them.
// Exception: the active freshly-created chat is injected above so it remains
// visible/selected until the user sends the first turn or switches away.
const withMessages=allMatched.filter(s=>
(s.message_count||0)>0 ||
_sessionAttentionState(s) ||
_isSessionEffectivelyStreaming(s) ||
!!s.active_stream_id ||
!!s.pending_user_message ||
(activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0)
);
const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length;
const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length;
if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){
_sessionSourceFilter='webui';
}
const sourceFiltered = _sessionSourceFilter==='cli'
? withMessages.filter(s=>_isCliSession(s))
: withMessages.filter(s=>!_isCliSession(s));
// The server is authoritative for profile scoping (#1611): it filters by
// active profile when no query param is set, and returns the aggregate when
// we send ?all_profiles=1. The renamed-root cross-alias (a row tagged
// 'default' matching active 'kinni' when kinni.is_default) lives server-side
// in _profiles_match, and a strict-equality client filter would reject those
// rows incorrectly. So we trust the wire data and skip the redundant client
// filter entirely.
const profileFiltered=sourceFiltered.filter(s=>
!s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)
);
// Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions
// with no project_id; otherwise filter to the matching project_id, or
// pass through when no filter is active.
const projectFiltered=
_activeProject===NO_PROJECT_FILTER
?profileFiltered.filter(s=>!s.project_id)
:(_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered);
// Filter archived unless toggle is on
const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const {
webuiSessionCount,
cliSessionCount,
profileFiltered,
sessionsRaw,
archivedCount,
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
const sessions=_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw);
_syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');
const animateRefresh=_sessionListRefreshAnimationPending;
_sessionListRefreshAnimationPending=false;

View File

@@ -6,6 +6,8 @@ import textwrap
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
CTL = REPO_ROOT / "ctl.sh"
@@ -78,20 +80,77 @@ def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int:
raise AssertionError(f"PID file was not written: {pid_file}")
def wait_for_file_text(path: Path, timeout: float = 3.0) -> str:
def wait_for_file_text(path: Path, timeout: float = 3.0, contains: str | None = None) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
if path.exists():
text = path.read_text(encoding="utf-8")
if text:
if text and (contains is None or contains in text):
return text
time.sleep(0.05)
raise AssertionError(f"File was not written: {path}")
def assert_path_in_text(path: Path, text: str) -> None:
assert str(path).replace("\\", "/") in text.replace("\\", "/")
def bash_path(path: Path) -> str:
raw = str(path.resolve()).replace("\\", "/")
if sys.platform == "win32" and len(raw) > 1 and raw[1] == ":":
return f"/{raw[0].lower()}{raw[2:]}"
return raw
def bash_pid(pid: int) -> int:
if sys.platform != "win32":
return pid
result = subprocess.run(
[
"bash",
"-lc",
"ps -W | awk -v winpid=\"$1\" '$4 == winpid { print $1; exit }'",
"_",
str(pid),
],
text=True,
capture_output=True,
timeout=3,
)
if result.returncode == 0 and result.stdout.strip():
return int(result.stdout.strip())
return pid
def windows_pid(pid: int) -> int | None:
if sys.platform != "win32":
return pid
result = subprocess.run(
[
"bash",
"-lc",
"ps -p \"$1\" -l | awk 'NR == 2 { print $4 }'",
"_",
str(pid),
],
text=True,
capture_output=True,
timeout=3,
)
if result.returncode == 0 and result.stdout.strip():
return int(result.stdout.strip())
return None
def start_fake_launchd_process() -> subprocess.Popen:
return subprocess.Popen(["bash", "-lc", "exec sleep 30"])
def _kill_tree(pid: int) -> None:
if sys.platform == "win32":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
winpid = windows_pid(pid)
if winpid is not None:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(winpid)], capture_output=True)
else:
try:
os.kill(pid, 9)
@@ -99,12 +158,28 @@ def _kill_tree(pid: int) -> None:
pass
def process_exists(pid: int) -> bool:
if sys.platform == "win32":
return (
subprocess.run(
["bash", "-lc", "kill -0 \"$1\"", "_", str(pid)],
text=True,
capture_output=True,
timeout=3,
).returncode
== 0
)
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
def assert_process_exits(pid: int, timeout: float = 3.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
if not process_exists(pid):
return
time.sleep(0.05)
_kill_tree(pid)
@@ -136,19 +211,20 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
try:
assert pid > 1
assert log_file.exists()
fake_output = wait_for_file_text(fake_log)
fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18991")
assert "bootstrap.py --no-browser --foreground" in fake_output
assert "host=0.0.0.0 port=18991" in fake_output
assert str(hermes_home / "webui") in fake_output
assert_path_in_text(hermes_home / "webui", fake_output)
status = run_ctl(tmp_path, "status")
assert status.returncode == 0
assert "running" in status.stdout
assert f"PID: {pid}" in status.stdout
assert "Bound: 0.0.0.0:18991" in status.stdout
assert f"Log: {log_file}" in status.stdout
assert_path_in_text(log_file, status.stdout)
finally:
stop = run_ctl(tmp_path, "stop")
assert stop.returncode == 0, stop.stderr + stop.stdout
_kill_tree(pid)
assert_process_exits(pid)
assert not pid_file.exists()
@@ -187,12 +263,13 @@ def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path):
assert result.returncode == 0, result.stderr + result.stdout
pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
try:
fake_output = wait_for_file_text(fake_log)
assert str(tmp_path / ".hermes" / "webui") in fake_output
fake_output = wait_for_file_text(fake_log, contains="host=127.0.0.1 port=8787")
assert_path_in_text(tmp_path / ".hermes" / "webui", fake_output)
assert "host-specific-webui" not in fake_output
finally:
stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
assert stop.returncode == 0, stop.stderr + stop.stdout
_kill_tree(pid)
assert_process_exits(pid)
@@ -225,12 +302,13 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
assert result.returncode == 0, result.stderr + result.stdout
pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
try:
fake_output = wait_for_file_text(fake_log)
fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18888")
assert "fake-python args:" in fake_output
assert "host=0.0.0.0 port=18888" in fake_output
finally:
stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
assert stop.returncode == 0, stop.stderr + stop.stdout
_kill_tree(pid)
assert_process_exits(pid)
@@ -283,11 +361,14 @@ def _write_fake_lsof(fake_bin, listening):
def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path):
if sys.platform == "win32":
pytest.skip("launchd conflict guard is a macOS path; fake launchctl PIDs are not stable under Git Bash")
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
_write_fake_launchctl(fake_bin, sleeper.pid)
sleeper = start_fake_launchd_process()
_write_fake_launchctl(fake_bin, bash_pid(sleeper.pid))
# launchd-owned PID IS listening on the requested (default) port → real conflict.
_write_fake_lsof(fake_bin, listening=True)
@@ -296,7 +377,7 @@ def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path):
tmp_path,
"start",
env={
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}",
"HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
},
)
@@ -320,8 +401,8 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
_write_fake_launchctl(fake_bin, sleeper.pid)
sleeper = start_fake_launchd_process()
_write_fake_launchctl(fake_bin, bash_pid(sleeper.pid))
# launchd-owned PID is alive but NOT listening on our (alternate) port → no conflict.
_write_fake_lsof(fake_bin, listening=False)
@@ -339,7 +420,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
tmp_path,
"start",
env={
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}",
"HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
"HERMES_WEBUI_PORT": "18992",
"HERMES_WEBUI_PYTHON": str(fake_python),
@@ -353,10 +434,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
started_pid = int(pid_file.read_text().strip())
finally:
if started_pid:
try:
os.kill(started_pid, 15)
except ProcessLookupError:
pass
_kill_tree(started_pid)
sleeper.terminate()
try:
sleeper.wait(timeout=3)

View File

@@ -18,10 +18,11 @@ def test_sidebar_has_separate_webui_and_cli_session_source_tabs():
def test_cli_filter_keeps_cli_rows_out_of_default_webui_list():
src = SESSIONS_JS.read_text(encoding="utf-8")
assert "const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length" in src
assert "const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length" in src
assert "? withMessages.filter(s=>_isCliSession(s))" in src
assert ": withMessages.filter(s=>!_isCliSession(s))" in src
assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src
assert "webuiSessionCount" in src
assert "cliSessionCount" in src
assert "const showCliOnly=_sessionSourceFilter==='cli';" in src
assert "if(showCliOnly ? !isCli : isCli) continue;" in src
def test_session_source_tabs_have_dedicated_sidebar_styles():

View File

@@ -91,4 +91,5 @@ def test_agent_side_cron_rows_keep_project_chip_visibility():
def test_session_list_project_filter_can_reveal_default_hidden_cron_rows():
src = ( __import__("pathlib").Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8")
assert "!s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)" in src
assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src
assert "if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;" in src

View File

@@ -25,7 +25,7 @@ def _run_search(query):
lives in its LAST message, capturing the JSON payload/status."""
import api.routes as routes
sessions_meta = [{"session_id": "s1", "title": "Untitled"}]
sessions_meta = [{"session_id": "s1", "title": "Untitled", "profile": "default"}]
session = SimpleNamespace(
session_id="s1",
messages=[
@@ -42,7 +42,9 @@ def _run_search(query):
with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch(
"api.routes.get_session", return_value=session
), patch("api.routes.j", side_effect=fake_j):
), patch("api.profiles.get_active_profile_name", return_value="default"), patch(
"api.routes.j", side_effect=fake_j
):
routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
return captured

View File

@@ -0,0 +1,52 @@
from types import SimpleNamespace
from unittest.mock import patch
from urllib.parse import urlparse
def _run_search(query):
import api.routes as routes
sessions_meta = [
{"session_id": "active-s", "title": "boring", "profile": "default"},
{"session_id": "other-s", "title": "secret needle title", "profile": "other"},
{"session_id": "other-content-s", "title": "boring", "profile": "other"},
]
sessions = {
"other-content-s": SimpleNamespace(messages=[{"role": "user", "content": "secret needle body"}]),
"active-s": SimpleNamespace(messages=[{"role": "user", "content": "nothing"}]),
"other-s": SimpleNamespace(messages=[]),
}
captured = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured["status"] = status
captured["payload"] = payload
with patch("api.routes.all_sessions", return_value=list(sessions_meta)), \
patch("api.routes.get_session", side_effect=lambda sid: sessions[sid]), \
patch("api.profiles.get_active_profile_name", return_value="default"), \
patch("api.routes.j", side_effect=fake_j):
routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
return captured
def test_empty_session_search_scopes_to_active_profile():
captured = _run_search("/api/sessions/search")
assert [s["session_id"] for s in captured["payload"]["sessions"]] == ["active-s"]
def test_title_search_should_not_return_other_profile_rows():
captured = _run_search("/api/sessions/search?q=needle&content=0")
assert captured["payload"]["count"] == 0
def test_content_search_should_not_return_other_profile_rows():
captured = _run_search("/api/sessions/search?q=needle&content=1&depth=0")
assert captured["payload"]["count"] == 0
def test_all_profiles_opt_in_keeps_aggregate_session_search():
captured = _run_search("/api/sessions/search?all_profiles=1")
assert captured["payload"]["all_profiles"] is True
assert [s["session_id"] for s in captured["payload"]["sessions"]] == [
"active-s",
"other-s",
"other-content-s",
]

View File

@@ -0,0 +1,37 @@
"""Regression coverage for single-pass sidebar session partitioning."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _partition_block() -> str:
start = SESSIONS_JS.index("function _partitionSidebarSessionRows(")
end = SESSIONS_JS.index("function renderSessionListFromCache()", start)
return SESSIONS_JS[start:end]
def test_render_uses_single_pass_partition_helper():
render_start = SESSIONS_JS.index("function renderSessionListFromCache()")
render_end = SESSIONS_JS.index("function _showProjectPicker", render_start)
render_body = SESSIONS_JS[render_start:render_end]
assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
assert "withMessages.filter(" not in render_body
def test_partition_helper_applies_message_source_project_and_archive_gates():
block = _partition_block()
assert "function _sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in SESSIONS_JS
assert "_sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in block
assert "if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0)" in block
assert "const showCliOnly=_sessionSourceFilter==='cli';" in block
assert "if(!_showArchived&&s.archived) continue;" in block
assert "if(s.archived) archivedCount++;" in block
assert "return {" in block
assert "profileFiltered," in block
assert "sessionsRaw," in block

View File

@@ -58,9 +58,12 @@ def test_unassigned_chip_filter_logic():
assert "_activeProject===NO_PROJECT_FILTER" in js, (
"renderSessionListFromCache must branch on the NO_PROJECT_FILTER sentinel"
)
assert "profileFiltered.filter(s=>!s.project_id)" in js, (
assert "if(_activeProject===NO_PROJECT_FILTER){" in js, (
"The Unassigned filter must select sessions without a project_id"
)
assert "if(s.project_id) continue;" in js, (
"The Unassigned filter must skip sessions with a project_id"
)
def test_unassigned_chip_only_shown_when_relevant():