Skip to content
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Added

- **PR #2534** by @Michaelyklam (closes #2508) — Add per-message pinning inside WebUI conversations. Message footers now include a pin toggle and transcript rows expose a right-click pin/unpin action; up to three pinned message previews persist on the session and render in the workspace side panel with click-to-jump and inline unpin controls.

## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh)

Expand Down
6 changes: 4 additions & 2 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
model_provider=None,
messages=None, created_at=None, updated_at=None,
tool_calls=None, pinned: bool=False, archived: bool=False,
tool_calls=None, pinned: bool=False, pinned_messages=None, archived: bool=False,
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
cache_read_tokens: int=0, cache_write_tokens: int=0,
Expand Down Expand Up @@ -398,6 +398,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.created_at = created_at or time.time()
self.updated_at = updated_at or time.time()
self.pinned = bool(pinned)
self.pinned_messages = pinned_messages if isinstance(pinned_messages, list) else []
self.archived = bool(archived)
self.project_id = project_id or None
self.profile = profile
Expand Down Expand Up @@ -466,7 +467,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
# Fields are listed in the order they should appear in the JSON file.
METADATA_FIELDS = [
'session_id', 'title', 'workspace', 'model', 'model_provider', 'created_at', 'updated_at',
'pinned', 'archived', 'project_id', 'profile',
'pinned', 'pinned_messages', 'archived', 'project_id', 'profile',
'input_tokens', 'output_tokens', 'estimated_cost',
'cache_read_tokens', 'cache_write_tokens',
'personality', 'active_stream_id',
Expand Down Expand Up @@ -626,6 +627,7 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
'updated_at': self.updated_at,
'last_message_at': last_message_at,
'pinned': self.pinned,
'pinned_messages': self.pinned_messages if isinstance(self.pinned_messages, list) else [],
'archived': self.archived,
'project_id': self.project_id,
'profile': self.profile,
Expand Down
70 changes: 70 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import html as _html
import copy
import hashlib
import io
import json
import logging
Expand Down Expand Up @@ -1951,6 +1952,43 @@ def _messages_include_tool_metadata(messages) -> bool:
return False


def _message_pin_text(message: dict) -> str:
if not isinstance(message, dict):
return ""
content = message.get("content", "")
if isinstance(content, list):
parts = []
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") in {"text", "thinking", "reasoning"}:
parts.append(str(part.get("text") or part.get("content") or part.get("thinking") or part.get("reasoning") or ""))
content = "\n".join(parts)
elif not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False)
return " ".join(str(content or "").split())


def _message_pin_key(message: dict, index: int) -> str:
role = str(message.get("role") or "") if isinstance(message, dict) else ""
ts = str(message.get("timestamp") or message.get("_ts") or "") if isinstance(message, dict) else ""
text = _message_pin_text(message)
digest = hashlib.sha256(f"{role}\0{ts}\0{text}".encode("utf-8")).hexdigest()[:16]
return f"msg:{index}:{role}:{digest}"


def _build_message_pin(message: dict, index: int, *, pinned_at=None) -> dict:
text = _message_pin_text(message)
role = str(message.get("role") or "message") if isinstance(message, dict) else "message"
return {
"message_index": int(index),
"message_key": _message_pin_key(message, index),
"role": role,
"preview": text[:220],
"pinned_at": pinned_at or time.time(),
}


def _merged_session_messages_for_display(session, cli_messages=None) -> list:
"""Return the message coordinate space exposed by ``GET /api/session``.

Expand Down Expand Up @@ -5504,6 +5542,38 @@ def handle_post(handler, parsed) -> bool:
except Exception as e:
return bad(handler, f"probe failed: {e}", 500)

# ── Message pin (POST) ──
if parsed.path == "/api/session/message-pin":
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
sid = body["session_id"]
try:
s = get_session(sid)
s = _ensure_full_session_before_mutation(sid, s)
except KeyError:
return bad(handler, "Session not found", 404)
try:
message_index = int(body.get("message_index"))
except (TypeError, ValueError):
return bad(handler, "message_index is required", 400)
with _get_session_agent_lock(sid):
messages = list(getattr(s, "messages", []) or [])
if message_index < 0 or message_index >= len(messages):
return bad(handler, "Message not found", 404)
current_pin = _build_message_pin(messages[message_index], message_index)
message_key = str(body.get("message_key") or current_pin["message_key"])
existing = [p for p in (getattr(s, "pinned_messages", []) or []) if isinstance(p, dict)]
existing = [p for p in existing if str(p.get("message_key") or "") != message_key and int(p.get("message_index") or -1) != message_index]
if bool(body.get("pinned", True)):
if len(existing) >= 3:
return bad(handler, "Up to 3 messages can be pinned", 400)
existing.append(current_pin)
s.pinned_messages = existing[-3:]
s.save(touch_updated_at=False)
return j(handler, {"ok": True, "session": s.compact()})

# ── Session pin (POST) ──
if parsed.path == "/api/session/pin":
try:
Expand Down
Binary file added docs/pr-media/2508/after-header-pin-popover.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/pr-media/2508/after-message-pins.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/pr-media/2508/before-no-message-pins.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions static/icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@
</span>
<span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
<button class="app-titlebar-pins" id="pinnedMessagesToggle" type="button" onclick="togglePinnedMessagesPopover(event)" aria-label="Pinned messages" title="Pinned messages" hidden>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16h14v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"/></svg>
<span class="app-titlebar-pins-count" id="pinnedMessagesCount">0</span>
</button>
<div class="pinned-messages-popover" id="pinnedMessagesPanel" hidden></div>
</div>
<div class="app-titlebar-spacer" aria-hidden="true"></div>
<button class="app-titlebar-reload" id="btnReload" onclick="window.location.reload()" type="button" aria-label="Reload" title="Reload page">
Expand Down
21 changes: 19 additions & 2 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,21 @@
.breadcrumb-link:hover{color:var(--text);background:var(--hover-bg);}
.breadcrumb-current{color:var(--text);font-weight:500;}
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
.app-titlebar-pins{display:inline-flex;align-items:center;gap:5px;margin-left:4px;padding:3px 7px;border:1px solid var(--border);border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:700;line-height:1;cursor:pointer;}
.app-titlebar-pins[hidden]{display:none!important;}
.app-titlebar-pins:hover,.app-titlebar-pins.open{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
.app-titlebar-pins-count{min-width:1ch;}
.pinned-messages-popover{position:fixed;z-index:1300;width:min(360px,calc(100vw - 24px));max-height:min(420px,calc(100vh - 72px));overflow:auto;padding:10px;border:1px solid var(--border2);border-radius:12px;background:var(--surface);box-shadow:0 18px 45px rgba(0,0,0,.32);}
.pinned-messages-popover[hidden]{display:none!important;}
.pinned-messages-head{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;margin-bottom:8px;}
.pinned-messages-head span:first-child{display:inline-flex;align-items:center;gap:5px;}
.pinned-messages-list{display:flex;flex-direction:column;gap:6px;}
.pinned-message-card{width:100%;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:7px;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);text-align:left;cursor:pointer;}
.pinned-message-card:hover{border-color:var(--accent-bg-strong);background:var(--hover-bg);}
.pinned-message-role{font-size:10px;text-transform:uppercase;color:var(--muted);font-weight:700;line-height:1.35;}
.pinned-message-preview{font-size:12px;line-height:1.4;color:var(--text);overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;}
.pinned-message-unpin{display:inline-flex;color:var(--muted);padding:1px;border-radius:5px;}
.pinned-message-unpin:hover{color:var(--accent-text);background:var(--accent-bg);}
.file-tree{flex:1;overflow-y:auto;padding:8px;}
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
.file-item:hover{background:var(--hover-bg);color:var(--text);}
Expand Down Expand Up @@ -1858,8 +1873,10 @@
/* ── Message action buttons (copy, edit, retry) ── */
.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;}
.msg-row:hover .msg-actions{opacity:1;}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
.msg-pin-btn.active{color:var(--accent-text);background:var(--accent-bg);}
.pin-jump-highlight{outline:2px solid var(--accent);outline-offset:4px;border-radius:10px;transition:outline-color .2s;}
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;}
.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);}
.selected-text-reply-btn:hover{background:var(--accent-bg);border-color:var(--accent-hover);}
Expand Down
Loading
Loading