Release v0.51.343 — Release LG (Phase-1 batch: #3883 + #3878 + #3880) (#3891)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-1 low-risk batch, each rebased onto fresh master + gated fresh: - #3883 (@rodboev, #3740): sidebar refreshes a stale message_count:0 index row from its sidecar when user_message_count>0 + sidecar mtime newer than index, self-healing the interrupted-stream stale-count case beyond compression lineage. - #3878 (@rodboev, #3833): manual workspace refresh clears the dir cache and re-fetches expanded descendants so background-written files become visible. - #3880 (@koshikai): translate the 11 remaining English strings in the ja locale. greptile flags evaluated: #3878 P1 relative-path + P2 stale-comment already fixed in PR head; #3883 P2 missing-snapshot-test already covered by the PR's own test_all_sessions_refreshes_stale_zero_count_snapshot_row_from_sidecar, P2 double-stat is a bounded cheap micro-opt (FOLD); #3880 'needs screenshots' rejected (in-place translation of existing keys, no UI shape change). Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: koshikai <koshikai@users.noreply.github.com>
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.343] — 2026-06-09 — Release LG (Phase-1 batch: sidebar stale-row refresh, workspace refresh cache, ja locale)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sidebar no longer hides a messageful session that carries a stale `message_count: 0` index row.** When a stream was interrupted before the final index write, a titled session could keep a stale zero count and get filtered out of the sidebar even though `state.db` still had its messages. The read-side refresh now also fires for a row whose indexed `message_count` is 0 while `user_message_count > 0` (and whose sidecar mtime is newer than the index), self-healing the count from the sidecar — generalizing the earlier compression-lineage-only refresh to the ordinary interrupted-stream case without reintroducing the per-poll hydration cost (the `user_message_count` gate keeps empty Untitled drafts cheap). (#3740, #3883)
|
||||
- **Workspace file tree now shows files created in expanded subdirectories after a manual refresh.** The refresh button only reloaded the current directory level and left cached expanded child directories untouched, so background writes (cron jobs, CLI agents, external tools) into those descendants stayed invisible until a full reload. A manual refresh now clears the directory cache and re-fetches expanded descendants without losing tree state. (#3833, #3878)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Japanese locale:** translated the 11 remaining English strings in the `ja` locale (Settings → Help panel labels/links, the cron-sessions setting, and the compression-queue composer placeholder). (#3880)
|
||||
|
||||
## [v0.51.342] — 2026-06-09 — Release LF (transcript + sidebar reliability: blank-transcript, missing-index stall, stale watermark)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2654,6 +2654,14 @@ def _strip_sidebar_internal_flags(sessions: list[dict]) -> None:
|
||||
session.pop('_show_pre_compression_snapshot', None)
|
||||
|
||||
|
||||
def _looks_like_stale_zero_message_row(session: dict) -> bool:
|
||||
"""Return True for indexed rows that likely need sidecar metadata repair."""
|
||||
return bool(
|
||||
int(session.get('message_count') or 0) == 0
|
||||
and int(session.get('user_message_count') or 0) > 0
|
||||
)
|
||||
|
||||
|
||||
def _row_may_need_sidecar_metadata_refresh(
|
||||
session: dict,
|
||||
*,
|
||||
@@ -2693,7 +2701,19 @@ def _row_may_need_sidecar_metadata_refresh(
|
||||
or session.get('_lineage_root_id')
|
||||
or session.get('_compression_segment_count')
|
||||
)
|
||||
return bool(lineage_shaped and sid and _sidecar_mtime_after_index_timestamp(session))
|
||||
needs_mtime_check = lineage_shaped or (
|
||||
sid and _looks_like_stale_zero_message_row(session)
|
||||
)
|
||||
if needs_mtime_check and _sidecar_mtime_after_index_timestamp(session):
|
||||
return True
|
||||
return False
|
||||
if (
|
||||
sid
|
||||
and _looks_like_stale_zero_message_row(session)
|
||||
and str(session.get('session_source') or '').strip().lower() != 'fork'
|
||||
and _sidecar_mtime_after_index_timestamp(session)
|
||||
):
|
||||
return True
|
||||
if session.get('message_count') is None or session.get('last_message_at') is None:
|
||||
return True
|
||||
return bool(sid and stale_snapshot_ids and sid in stale_snapshot_ids)
|
||||
|
||||
@@ -3369,14 +3369,14 @@ const LOCALES = {
|
||||
plugins_provider_no_hooks: 'プロバイダープラグイン — エージェント可視フックなし',
|
||||
plugins_load_failed: 'プラグインの読み込みに失敗しました: ',
|
||||
settings_tab_system: 'システム',
|
||||
settings_tab_help: 'Help',
|
||||
settings_help_meta: 'Resources and support for Hermes WebUI.',
|
||||
settings_help_docs_label: 'Documentation',
|
||||
settings_help_docs_desc: 'Guides, configuration reference, and the full Hermes WebUI README.',
|
||||
settings_help_docs_link: 'Open Documentation',
|
||||
settings_help_issue_label: 'Having an issue?',
|
||||
settings_help_issue_desc: 'Search existing reports or open a new one on GitHub.',
|
||||
settings_help_issue_link: 'Open GitHub Issues',
|
||||
settings_tab_help: 'ヘルプ',
|
||||
settings_help_meta: 'Hermes WebUI のリソースとサポート。',
|
||||
settings_help_docs_label: 'ドキュメント',
|
||||
settings_help_docs_desc: 'ガイド、設定リファレンス、および Hermes WebUI README の全文。',
|
||||
settings_help_docs_link: 'ドキュメントを開く',
|
||||
settings_help_issue_label: '問題が発生しましたか?',
|
||||
settings_help_issue_desc: 'GitHub で既存のレポートを検索するか、新しく報告を作成します。',
|
||||
settings_help_issue_link: 'GitHub Issues を開く',
|
||||
settings_title: '設定',
|
||||
settings_save_btn: '設定を保存',
|
||||
settings_label_model: 'デフォルトモデル',
|
||||
@@ -3420,7 +3420,7 @@ const LOCALES = {
|
||||
settings_label_sidebar_density: 'サイドバー密度',
|
||||
cmd_reasoning: '思考表示の切り替え (表示/非表示)、努力レベル設定、現在状態の確認',
|
||||
settings_label_external_sessions: '非WebUIセッションを表示',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_cron_sessions: 'Cronセッションを表示',
|
||||
settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示',
|
||||
settings_label_sync_insights: 'インサイトに同期',
|
||||
settings_label_check_updates: 'アップデートを確認',
|
||||
@@ -3700,7 +3700,7 @@ const LOCALES = {
|
||||
settings_auto_title_refresh_20: '20 回ごと',
|
||||
settings_desc_auto_title_refresh: '最新のやり取りに基づいてセッションタイトルを自動再生成し、会話の進行に合わせて適切に保ちます。LLM タイトル生成モデルの設定が必要です。',
|
||||
settings_desc_external_sessions: 'CLI、Telegram、Discord、Slack その他のチャネルからの会話をセッション一覧に表示します。クリックでインポートして続行できます。',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_cron_sessions: 'Cronジョブの出力をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。高頻度のジョブはサイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。',
|
||||
settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。',
|
||||
settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。',
|
||||
@@ -4136,7 +4136,7 @@ const LOCALES = {
|
||||
composer_stop: '生成を停止',
|
||||
composer_disabled_clarify: '確認要求に応答してください',
|
||||
composer_disabled_compression: '圧縮の完了待ち',
|
||||
composer_compression_will_queue: 'Type a message — it will queue and send after compression',
|
||||
composer_compression_will_queue: 'メッセージを入力してください — 圧縮完了後にキューに追加され、送信されます',
|
||||
composer_disabled_empty: '送信するメッセージを入力してください',
|
||||
composer_mobile_workspace: 'ワークスペース',
|
||||
composer_mobile_model: 'モデル',
|
||||
|
||||
@@ -1414,7 +1414,7 @@
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUpDir" data-tooltip="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFile" data-tooltip="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFolder" data-tooltip="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" onclick="if(S.session)refreshWorkspacePanel()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUploadWorkspace" data-tooltip="Upload file" onclick="triggerWorkspaceUpload()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnWorkspacePrefs" data-tooltip="Workspace options" data-i18n-title="workspace_options" aria-haspopup="true" aria-expanded="false" onclick="toggleWorkspacePrefsMenu(event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg><span class="workspace-prefs-dot" id="workspacePrefsDot" hidden></span></button>
|
||||
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
|
||||
@@ -368,12 +368,13 @@ async function openArtifactPath(path){
|
||||
|
||||
async function loadDir(path, opts={}){
|
||||
const preservePreview=!!(opts&&opts.preservePreview);
|
||||
const refreshExpanded=!!(opts&&opts.refreshExpanded);
|
||||
if(!S.session)return;
|
||||
const sessionId=S.session.session_id;
|
||||
try{
|
||||
if(!path||path==='.'){
|
||||
if(!path||path==='.'||refreshExpanded){
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state after root and refresh resets
|
||||
}
|
||||
S.currentDir=path||'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`);
|
||||
@@ -383,7 +384,7 @@ async function loadDir(path, opts={}){
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
|
||||
if(!path||path==='.'){
|
||||
if(!path||path==='.'||refreshExpanded){
|
||||
const expanded=S._expandedDirs||new Set();
|
||||
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
|
||||
if(pending.length){
|
||||
@@ -411,6 +412,12 @@ async function loadDir(path, opts={}){
|
||||
}catch(e){console.warn('loadDir',e);}
|
||||
}
|
||||
|
||||
function refreshWorkspacePanel(){
|
||||
if(!S.session)return;
|
||||
const targetDir = S.currentDir || '.';
|
||||
loadDir(targetDir,{refreshExpanded:true});
|
||||
}
|
||||
|
||||
async function _refreshGitBadge(){
|
||||
const badge=$('gitBadge');
|
||||
if(!badge||!S.session)return;
|
||||
|
||||
50
tests/test_issue3833_workspace_refresh.py
Normal file
50
tests/test_issue3833_workspace_refresh.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Regression coverage for #3833: refresh should clear stale expanded subtree cache."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = (REPO_ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
WORKSPACE_JS = (REPO_ROOT / "static/workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_block(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{name}() not found"
|
||||
params_end = src.find("){", start)
|
||||
assert params_end != -1, f"{name}() body not found"
|
||||
brace = params_end + 1
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
ch = src[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : idx + 1]
|
||||
raise AssertionError(f"{name}() body did not close")
|
||||
|
||||
|
||||
def test_refresh_button_uses_workspace_refresh_helper():
|
||||
"""Workspace refresh should go through a dedicated path that clears cache."""
|
||||
assert 'id="btnRefreshPanel"' in INDEX_HTML
|
||||
assert 'onclick="if(S.session)refreshWorkspacePanel()"' in INDEX_HTML
|
||||
assert "onclick=\"if(S.session)loadDir(S.currentDir)\"" not in INDEX_HTML
|
||||
|
||||
|
||||
def test_refresh_workspace_panel_reloads_current_directory_with_expanded_refresh():
|
||||
body = _function_block(WORKSPACE_JS, "refreshWorkspacePanel")
|
||||
compact = body.replace(" ", "")
|
||||
assert "consttargetDir=S.currentDir||'.';" in compact
|
||||
assert "loadDir(targetDir,{refreshExpanded:true});" in compact
|
||||
|
||||
|
||||
def test_load_dir_can_refresh_all_expanded_descendants_when_requested():
|
||||
block = _function_block(WORKSPACE_JS, "loadDir")
|
||||
compact = block.replace(" ", "")
|
||||
assert "constrefreshExpanded=!!(opts&&opts.refreshExpanded);" in compact
|
||||
assert "if(!path||path==='.'||refreshExpanded){" in compact
|
||||
assert "constexpanded=S._expandedDirs||newSet();" in compact
|
||||
assert "constpending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);" in compact
|
||||
@@ -869,6 +869,118 @@ def test_all_sessions_refreshes_stale_visible_continuation_metadata(monkeypatch)
|
||||
assert rows[0]["last_message_at"] == 103.0
|
||||
|
||||
|
||||
def test_all_sessions_refreshes_stale_zero_count_row_from_sidecar(monkeypatch):
|
||||
"""A zero-message indexed row can still have real transcript content on disk."""
|
||||
session = Session(
|
||||
session_id="stale_zero_count",
|
||||
title="Recovered Session",
|
||||
messages=[
|
||||
{"role": "user", "content": "first", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "second", "timestamp": 101.0},
|
||||
],
|
||||
updated_at=101.0,
|
||||
last_message_at=101.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "stale_zero_count",
|
||||
"title": "Recovered Session",
|
||||
"message_count": 0,
|
||||
"user_message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 1.0,
|
||||
"last_message_at": 1.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["stale_zero_count"]
|
||||
assert rows[0]["message_count"] == 2
|
||||
assert rows[0]["last_message_at"] == 101.0
|
||||
|
||||
|
||||
def test_all_sessions_refreshes_stale_zero_count_snapshot_row_from_sidecar(monkeypatch):
|
||||
"""Snapshot rows follow the same stale-zero sidecar refresh path."""
|
||||
session = Session(
|
||||
session_id="stale_zero_snapshot_count",
|
||||
title="Recovered Snapshot Session",
|
||||
messages=[
|
||||
{"role": "user", "content": "first", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "second", "timestamp": 101.0},
|
||||
],
|
||||
updated_at=101.0,
|
||||
last_message_at=101.0,
|
||||
pre_compression_snapshot=True,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "stale_zero_snapshot_count",
|
||||
"title": "Recovered Snapshot Session",
|
||||
"message_count": 0,
|
||||
"user_message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 1.0,
|
||||
"last_message_at": 1.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"pre_compression_snapshot": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["stale_zero_snapshot_count"]
|
||||
assert rows[0]["message_count"] == 2
|
||||
assert rows[0]["last_message_at"] == 101.0
|
||||
|
||||
|
||||
def test_all_sessions_skips_refresh_for_real_empty_untitled_drafts(monkeypatch):
|
||||
"""Keep genuine empty drafts on the cheap path when they have no user turns."""
|
||||
draft = Session(
|
||||
session_id="untitled_empty_draft",
|
||||
title="Untitled",
|
||||
messages=[],
|
||||
updated_at=100.0,
|
||||
last_message_at=100.0,
|
||||
)
|
||||
draft.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "untitled_empty_draft",
|
||||
"title": "Untitled",
|
||||
"message_count": 0,
|
||||
"user_message_count": 0,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 100.0,
|
||||
"last_message_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("empty draft should not refresh sidecar")):
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_all_sessions_does_not_refresh_plain_branch_fork_from_sidecar(monkeypatch):
|
||||
"""A plain /branch fork (session_source='fork') must NOT trigger a sidecar refresh.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user