Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@

## [Unreleased]

## [v0.51.135] — 2026-05-25 — Release DG (stage-batch17 — 9-PR small-fix batch)

### Added

- Added a proposed canonical session resolution RFC covering URL routes, query parameters, localStorage, sidebar rows, and compression-lineage IDs so future session-routing fixes have one review contract.

### Fixed

- Browser session links that use the API-style `?session_id=<id>` query parameter now open the requested conversation instead of falling back to the last locally stored session.
- Gateway status now treats existing messaging-session metadata as configured when `gateway.status` is unavailable, avoiding a misleading "Gateway not configured" warning for multi-container deployments with active gateway sessions.
- Session sidebar Archive/Delete menu actions now repaint from local sidebar state immediately after the server confirms the mutation, instead of waiting for the full `/api/sessions` refresh before the row disappears.
- Clarification dialogs now reserve transcript space while open or collapsed, so the question prompt no longer covers the assistant text needed to answer it.
- Chat uploads now send the absolute server-side path for image attachments in the agent text context, restoring immediate tool access (e.g. `vision_analyze`) to files uploaded in the current turn.
- Pending uploaded-file user turns no longer double-render when both the optimistic bubble and the server's pending-message hydration produce the same `[Attached files: ...]` suffix.

## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults)

### Fixed
Expand Down
16 changes: 15 additions & 1 deletion api/agent_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,24 @@ def _gateway_root_pid_path() -> Path | None:
Gateway runtime files are root-level singletons. A profile-scoped WebUI
process may have HERMES_HOME=<root>/profiles/<name>, but gateway.pid,
gateway.lock, and gateway_state.json still live under <root>.

When the root-level gateway.pid is absent (profile-scoped gateway
deployments write it under <root>/profiles/<name>/), fall back to the
active profile's directory so the gateway is detected correctly.
"""
try:
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / _GATEWAY_PID_FILE
root_pid = get_default_hermes_root() / _GATEWAY_PID_FILE
if root_pid.exists():
return root_pid
try:
from api.profiles import get_active_hermes_home
profile_pid = Path(get_active_hermes_home()) / _GATEWAY_PID_FILE
if profile_pid.exists():
return profile_pid
except Exception:
pass
return root_pid
except Exception:
return None

Expand Down
2 changes: 1 addition & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4754,7 +4754,7 @@ def handle_get(handler, parsed) -> bool:
configured = True
else: # alive is None → gateway not configured / unavailable
running = bool(identity_map)
configured = False
configured = bool(identity_map)

platforms_set: set[str] = set()
for meta in identity_map.values():
Expand Down
5 changes: 5 additions & 0 deletions docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ does not change runtime behavior, maintainer policy, bot behavior, or CI gates.
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
repairs. Start here for narrow fixes that keep the existing WebUI execution
path.
- [`docs/rfcs/canonical-session-resolution.md`](rfcs/canonical-session-resolution.md):
proposed contract for resolving URL routes, query parameters, localStorage,
sidebar rows, and compression-lineage IDs to one canonical visible session
target. Start here for session routing, boot restore, stale parent, or
compression-tip selection changes.
- [`docs/rfcs/hermes-run-adapter-contract.md`](rfcs/hermes-run-adapter-contract.md):
proposed event/control contract, runtime-state ownership matrix,
acceptance-test catalog, and reversible migration gates for moving WebUI
Expand Down
Binary file added docs/images/pr-2919-clarify-after-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-2919-clarify-before-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ First-time contributor RFCs should be discussed in an issue before opening a PR.
— #2361 consistency rules for keeping transcript, model context, live streams,
replay, compression, and session metadata coherent during active and recovered
WebUI runs.
- [`canonical-session-resolution.md`](canonical-session-resolution.md) — #2361
focused contract for resolving URL, query parameter, localStorage, sidebar,
and compression-lineage session IDs to one canonical visible chat target.
- [`turn-journal.md`](turn-journal.md) — Crash-safe WebUI turn journal for
recovering interrupted chat submissions.
124 changes: 124 additions & 0 deletions docs/rfcs/canonical-session-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Canonical Session Resolution Contract

- **Status:** Proposed
- **Author:** @ai-ag2026
- **Created:** 2026-05-25
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`webui-run-state-consistency-contract.md`](webui-run-state-consistency-contract.md)

## Problem

WebUI can reach the same conversation through several browser-facing entrypoints:

- a URL route such as `/session/<session_id>`,
- a query parameter such as `?session=<session_id>` or `?session_id=<session_id>`,
- the browser's `localStorage` active-session value,
- sidebar rows built from `/api/sessions`,
- direct session open actions from links, search, or imported session lists,
- browser boot restore after reload, auth redirect, or PWA resume.

After automatic compression, those entrypoints can point at different rows in one
logical conversation lineage. A pre-compression parent snapshot can remain a
valid archived session while the user-facing conversation tip has moved to a
newer continuation. If each caller resolves IDs independently, the UI can appear
to lose the session, reopen an old one-message snapshot, duplicate sidebar rows,
or prefer the wrong transcript even though durable data is still present.

This contract defines the expected resolution semantics for those entrypoints. It
is intentionally narrower than the run adapter RFC: this is about choosing the
correct visible session target, not moving execution ownership.

## Goals

- Define one canonical browser-facing resolution concept for sessions and
compression lineage.
- Make URL, query parameter, localStorage, sidebar, and direct-open behavior use
the same mental model.
- Preserve archived parent snapshots without letting them become the default
active target when a continuation exists.
- Give reviewers a small checklist for future session-routing, sidebar, and
compression-lineage changes.

## Non-goals

- Do not delete archived `pre_compression_snapshot` rows.
- Do not merge or rewrite session files as part of this contract.
- Do not replace state.db/session sidecar reconciliation.
- Do not require a new backend endpoint before narrow frontend guards can land.
- Do not change explicit history browsing when the user deliberately opens an
archived snapshot as a record.

## Terms

| Term | Meaning |
|---|---|
| Requested session ID | The ID supplied by route, query parameter, localStorage, sidebar click, or direct session open. |
| Canonical visible session | The session row WebUI should display by default for normal chat navigation. |
| `canonical_visible_session_id` | Proposed field/name for an API or helper output that identifies the canonical visible session. |
| Compression snapshot | A preserved archived parent row with `pre_compression_snapshot` set. |
| Continuation session | The active child/tip created after compression, usually represented by `continuation_session_id`, `_lineage_tip_id`, or newer lineage metadata. |
| Lineage relation | Links such as `parent_session_id`, `_lineage_root_id`, `_lineage_tip_id`, and `_compression_segment_count` that connect rows belonging to one logical conversation. |

## Resolution Rules

1. **Directly valid non-snapshot IDs stay stable.** If the requested session ID
exists and is not a `pre_compression_snapshot`, it should normally resolve to
itself.
2. **Snapshot parents defer to visible continuation tips.** If the requested
session ID is a `pre_compression_snapshot` and the session list has a newer
non-snapshot continuation in the same lineage, normal chat navigation should
resolve to that continuation as the `canonical_visible_session_id`.
3. **Explicit archive/history inspection remains possible.** A future UI affordance
may intentionally open a snapshot as a historical record, but that should be a
distinct mode from ordinary boot restore, URL restore, or sidebar continuation.
4. **Local browser state is advisory.** `localStorage` may remember the last active
ID, but browser boot restore must treat it as a requested session ID and still
run canonical resolution before rendering.
5. **Query aliases share the same resolver.** `?session=...`, `?session_id=...`,
and `/session/...` should feed the same requested-ID path instead of carrying
separate precedence rules.
6. **Sidebar collapse and session loading agree.** The row chosen as the visible
representative for a lineage should match the target opened by `loadSession()`
for that lineage during ordinary navigation.
7. **404 self-heal is separate from lineage resolution.** Missing/deleted sessions
should still use the stale-route recovery path. A present archived parent with
a live continuation is not a 404; it is a canonicalization problem.

## Entry Point Matrix

| Entry point | Input | Expected resolution |
|---|---|---|
| URL route | `/session/<id>` | Treat `<id>` as requested; resolve to canonical visible session before ordinary render. |
| Query parameter | `?session=<id>` or `?session_id=<id>` | Same as URL route. Query spelling must not change the target semantics. |
| localStorage | last active session ID | Advisory requested ID during browser boot restore; canonicalize before render. |
| Sidebar click | visible row ID or lineage representative | Open the same canonical visible session that the row represents. |
| Direct session open | programmatic call/search/import link | Use the shared requested-ID resolver unless the caller explicitly opts into archive inspection. |
| Browser boot restore | URL and/or localStorage state after reload/auth/PWA resume | Prefer explicit URL/query input, then localStorage, then canonicalize the requested ID. |

## Review Checklist

For PRs that touch session routing, compression lineage, sidebar collapse, boot
restore, direct session open, or URL parsing, answer:

- Which entrypoints provide the requested session ID?
- Does the code path accept both route and query parameter forms where relevant?
- Does localStorage go through the same canonicalization path as URL restore?
- Can a `pre_compression_snapshot` become the default active chat when a
non-snapshot `continuation_session_id` / `_lineage_tip_id` exists?
- Do sidebar collapse and `loadSession()` pick the same visible representative?
- Is missing-session 404 recovery kept distinct from present-but-archived lineage
canonicalization?
- What regression proves route, query parameter, localStorage, and sidebar paths
agree for compressed lineage rows?

## Rollout Plan

1. Document this proposed contract and link it from the public contract index.
2. Keep narrow bugfixes small while referencing the relevant rule they preserve.
3. Add shared frontend helper coverage for URL/query/localStorage/sidebar
requested-ID inputs.
4. If backend session APIs later expose `canonical_visible_session_id`, make the
frontend resolver prefer the backend value while preserving client fallback for
older WebUI servers.
5. If #1925 moves execution/session ownership behind an adapter, carry this
contract forward as an adapter-facing session-navigation invariant.
56 changes: 55 additions & 1 deletion static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ async function send(){
setComposerStatus('');

const uploadedNames=uploaded.map(u=>u.name||u);
const uploadedPaths=uploaded.map(u=>u&&u.is_image?(u.name||u.filename||u):(u.path||u.name||u));
const uploadedPaths=uploaded.map(u=>u&&u.path?u.path:(u&&u.name?u.name:(u&&u.filename?u.filename:u)));
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
Expand Down Expand Up @@ -2677,12 +2677,63 @@ function _syncClarifyCollapseButton(card) {
collapse.title = label;
}

let _clarifyResizeListenerReady = false;

function _clarifyMessagesNearBottom(messages) {
if (!messages) return false;
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 150;
}

function _syncClarifyTranscriptSpace(card, opts) {
opts = opts || {};
const messages = $("messages");
if (!messages) return;
const wasNearBottom = _clarifyMessagesNearBottom(messages);
if (!card || !card.classList.contains("visible")) {
messages.classList.remove("clarify-open");
messages.classList.remove("clarify-collapsed");
messages.style.removeProperty("--clarify-card-height");
messages.style.removeProperty("--clarify-dock-height");
if (wasNearBottom && typeof scrollToBottom === "function" && typeof requestAnimationFrame === "function") {
requestAnimationFrame(scrollToBottom);
}
return;
}
const collapsed = card.classList.contains("collapsed");
messages.classList.add("clarify-open");
messages.classList.toggle("clarify-collapsed", collapsed);
const measure = () => {
if (!card.classList.contains("visible")) return;
const target = collapsed ? card : (card.querySelector(".clarify-inner") || card);
const h = target && target.getBoundingClientRect().height;
if (h > 0) {
messages.style.setProperty(collapsed ? "--clarify-dock-height" : "--clarify-card-height", Math.ceil(h + 24) + "px");
}
if (wasNearBottom && typeof scrollToBottom === "function") scrollToBottom();
};
if (opts.immediate) measure();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure);
setTimeout(measure, 420);
}

function _ensureClarifyResizeListener() {
if (_clarifyResizeListenerReady || typeof window === "undefined") return;
_clarifyResizeListenerReady = true;
window.addEventListener("resize", () => {
const card = $("clarifyCard");
if (card && card.classList.contains("visible")) {
_syncClarifyTranscriptSpace(card, {immediate: true});
}
}, {passive: true});
}

function toggleClarifyCardCollapsed(forceCollapsed) {
const card = $("clarifyCard");
if (!card) return;
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
card.classList.toggle("collapsed", collapsed);
_syncClarifyCollapseButton(card);
_syncClarifyTranscriptSpace(card, {immediate: true});
}

function _clearClarifyHideTimer() {
Expand Down Expand Up @@ -2797,6 +2848,7 @@ function hideClarifyCard(force=false, reason="dismissed") {
_clarifySessionId = null;
_resetClarifyCardState();
card.classList.remove("visible");
_syncClarifyTranscriptSpace(null);
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
$("clarifyQuestion").textContent = "";
$("clarifyChoices").innerHTML = "";
Expand Down Expand Up @@ -2911,8 +2963,10 @@ function showClarifyCard(pending) {
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
}
_clarifySetControlsDisabled(false, false);
_ensureClarifyResizeListener();
card.classList.add("visible");
_syncClarifyCollapseButton(card);
_syncClarifyTranscriptSpace(card, {immediate: true});
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Move focus to clarify input synchronously (not in setTimeout) and
// only if the user wasn't mid-type in the composer textarea.
Expand Down
30 changes: 26 additions & 4 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ async function loadSession(sid){
if(!Array.isArray(messages)) return false;
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,messages):null;
if(!pendingMsg) return false;
if(messages.some(existing=>_sameTranscriptMessage(existing,pendingMsg))) return false;
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
if(liveAssistantIdx>=0) messages.splice(liveAssistantIdx,0,pendingMsg);
else messages.push(pendingMsg);
Expand Down Expand Up @@ -1544,6 +1545,24 @@ function _sessionArchiveToast(response, session){
function _sessionDeleteDescription(session){
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
}
function _optimisticallyArchiveSessionInList(sid, archived){
if(!sid||!Array.isArray(_allSessions)) return;
let changed=false;
_allSessions=_allSessions.map(s=>{
if(!s||s.session_id!==sid) return s;
changed=true;
return {...s,archived:!!archived};
});
if(changed) renderSessionListFromCache();
}
function _optimisticallyRemoveSessionFromList(sid){
if(!sid||!Array.isArray(_allSessions)) return;
const before=_allSessions.length;
_allSessions=_allSessions.filter(s=>!s||s.session_id!==sid);
if(_selectedSessions&&_selectedSessions.has(sid)) _selectedSessions.delete(sid);
if(typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid);
if(_allSessions.length!==before) renderSessionListFromCache();
}

function _sessionIdFromLocation(){
if(typeof window==='undefined'||!window.location) return null;
Expand All @@ -1556,7 +1575,7 @@ function _sessionIdFromLocation(){
}
try{
const qs=new URLSearchParams(window.location.search||'');
return qs.get('session')||null;
return qs.get('session')||qs.get('session_id')||null;
}catch(_e){return null;}
}
function _sessionUrlForSid(sid){
Expand Down Expand Up @@ -1866,9 +1885,10 @@ function _openSessionActionMenu(session, anchorEl){
closeSessionActionMenu();
try{
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
_optimisticallyArchiveSessionInList(session.session_id,!session.archived);
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await renderSessionList();
void renderSessionList();
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
Expand All @@ -1882,9 +1902,10 @@ function _openSessionActionMenu(session, anchorEl){
closeSessionActionMenu();
try{
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:true})});
_optimisticallyArchiveSessionInList(session.session_id,true);
session.archived=true;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=true;
await renderSessionList();
void renderSessionList();
showToast(t('session_hidden'));
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
Expand Down Expand Up @@ -3874,6 +3895,7 @@ async function deleteSession(sid){
let response=null;
try{
response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
_optimisticallyRemoveSessionFromList(sid);
_clearHandoffStorageForSession(sid);
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
if(S.session&&S.session.session_id===sid){
Expand All @@ -3894,7 +3916,7 @@ async function deleteSession(sid){
}
}
showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
await renderSessionList();
void renderSessionList();
}

// ── Project helpers ─────────────────────────────────────────────────────
Expand Down
Loading
Loading