diff --git a/api/models.py b/api/models.py index 33e736b251..a71e76f1ac 100644 --- a/api/models.py +++ b/api/models.py @@ -1,5 +1,7 @@ """Hermes Web UI -- Session model and in-memory session store.""" import collections +import datetime +import hashlib import json import logging import os @@ -1231,6 +1233,230 @@ def import_cli_session( # ── CLI session bridge ────────────────────────────────────────────────────── +CLAUDE_CODE_SOURCE = 'claude_code' +CLAUDE_CODE_SOURCE_LABEL = 'Claude Code' +CLAUDE_CODE_MAX_FILES = 200 +CLAUDE_CODE_MAX_FILE_BYTES = 10 * 1024 * 1024 +CLAUDE_CODE_MAX_MESSAGES_PER_FILE = 1000 +CLAUDE_CODE_MAX_CONTENT_CHARS = 200_000 + + +def _default_claude_code_projects_dir() -> Path | None: + """Resolve the Claude Code projects directory without touching real home in tests.""" + override = os.getenv('HERMES_WEBUI_CLAUDE_PROJECTS_DIR') + if override: + return Path(override).expanduser() + if os.getenv('HERMES_WEBUI_TEST_STATE_DIR'): + return None + return Path.home() / '.claude' / 'projects' + + +def _claude_code_session_id(path: Path) -> str: + digest = hashlib.sha256(str(path.expanduser().resolve()).encode('utf-8')).hexdigest()[:24] + return f'{CLAUDE_CODE_SOURCE}_{digest}' + + +def _parse_claude_code_timestamp(value): + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except ValueError: + pass + try: + return datetime.datetime.fromisoformat(text.replace('Z', '+00:00')).timestamp() + except Exception: + return None + + +def _extract_claude_code_text(content) -> str: + if content is None: + return '' + if isinstance(content, str): + return content[:CLAUDE_CODE_MAX_CONTENT_CHARS] + if isinstance(content, list): + parts = [] + used = 0 + for item in content: + text = '' + if isinstance(item, str): + text = item + elif isinstance(item, dict): + text = item.get('text') or item.get('content') or '' + if not text: + continue + text = str(text) + remaining = CLAUDE_CODE_MAX_CONTENT_CHARS - used + if remaining <= 0: + break + parts.append(text[:remaining]) + used += len(parts[-1]) + return '\n'.join(parts) + if isinstance(content, dict): + return _extract_claude_code_text(content.get('text') or content.get('content')) + return str(content)[:CLAUDE_CODE_MAX_CONTENT_CHARS] + + +def _parse_claude_code_jsonl(path: Path, *, max_messages: int = CLAUDE_CODE_MAX_MESSAGES_PER_FILE) -> tuple[list[dict], str | None, float | None, float | None]: + messages: list[dict] = [] + summary_title = None + first_ts = None + last_ts = None + try: + with path.open('r', encoding='utf-8', errors='replace') as fh: + for line in fh: + if len(messages) >= max_messages: + break + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + except Exception: + continue + if not isinstance(raw, dict): + continue + if not summary_title: + summary = raw.get('summary') or raw.get('title') + if isinstance(summary, str) and summary.strip(): + summary_title = ' '.join(summary.split())[:80] + records = raw.get('messages') if isinstance(raw.get('messages'), list) else None + if records is None: + records = [raw.get('message') if isinstance(raw.get('message'), dict) else raw] + for record in records: + if len(messages) >= max_messages: + break + if not isinstance(record, dict): + continue + msg = record.get('message') if isinstance(record.get('message'), dict) else record + role = str(msg.get('role') or record.get('role') or raw.get('role') or raw.get('type') or '').strip().lower() + if role == 'human': + role = 'user' + if role not in {'user', 'assistant', 'system', 'tool'}: + continue + content = _extract_claude_code_text(msg.get('content') if 'content' in msg else record.get('content')) + if not content.strip(): + continue + ts = _parse_claude_code_timestamp( + msg.get('timestamp') + or record.get('timestamp') + or raw.get('timestamp') + or raw.get('created_at') + ) + if ts is not None: + first_ts = ts if first_ts is None else min(first_ts, ts) + last_ts = ts if last_ts is None else max(last_ts, ts) + item = {'role': role, 'content': content} + if ts is not None: + item['timestamp'] = ts + messages.append(item) + except Exception: + return [], None, None, None + return messages, summary_title, first_ts, last_ts + + +def _iter_claude_code_jsonl_files(projects_dir: Path | str | None = None, *, max_files: int = CLAUDE_CODE_MAX_FILES, max_file_bytes: int = CLAUDE_CODE_MAX_FILE_BYTES): + root = Path(projects_dir).expanduser() if projects_dir is not None else _default_claude_code_projects_dir() + if root is None: + return + try: + if root.is_symlink(): + return + root = root.resolve(strict=False) + if not root.exists() or not root.is_dir(): + return + yielded = 0 + for project_dir in sorted(root.iterdir(), key=lambda p: p.name): + if yielded >= max_files: + return + try: + if project_dir.is_symlink() or not project_dir.is_dir(): + continue + for path in sorted(project_dir.iterdir(), key=lambda p: p.name): + if yielded >= max_files: + return + if path.is_symlink() or not path.is_file() or path.suffix.lower() != '.jsonl': + continue + try: + if path.stat().st_size > max_file_bytes: + continue + except OSError: + continue + yielded += 1 + yield path + except OSError: + continue + except OSError: + return + + +def _claude_code_title(messages: list[dict], summary_title: str | None) -> str: + if summary_title: + return summary_title + for msg in messages: + if msg.get('role') == 'user': + text = ' '.join(str(msg.get('content') or '').split()) + if text: + return text[:80] + return 'Claude Code Session' + + +def get_claude_code_sessions(projects_dir: Path | str | None = None, *, max_files: int = CLAUDE_CODE_MAX_FILES, max_file_bytes: int = CLAUDE_CODE_MAX_FILE_BYTES) -> list: + """Read Claude Code JSONL sessions as read-only external-agent rows. + + The bridge is additive and defensive: it skips symlinks, oversized files, + malformed lines, and per-file errors rather than crashing WebUI session + listing. Tests pass ``projects_dir`` fixtures so Michael's real ~/.claude is + never read during test runs. + """ + sessions = [] + for path in _iter_claude_code_jsonl_files(projects_dir, max_files=max_files, max_file_bytes=max_file_bytes) or []: + messages, summary_title, first_ts, last_ts = _parse_claude_code_jsonl(path) + if not messages: + continue + sid = _claude_code_session_id(path) + sessions.append({ + 'session_id': sid, + 'title': _claude_code_title(messages, summary_title), + 'workspace': str(get_last_workspace()), + 'model': 'claude-code', + 'message_count': len(messages), + 'created_at': first_ts or last_ts or path.stat().st_mtime, + 'updated_at': last_ts or first_ts or path.stat().st_mtime, + 'last_message_at': last_ts or first_ts or path.stat().st_mtime, + 'pinned': False, + 'archived': False, + 'project_id': None, + 'profile': None, + 'source_tag': CLAUDE_CODE_SOURCE, + 'raw_source': CLAUDE_CODE_SOURCE, + 'session_source': 'external_agent', + 'source_label': CLAUDE_CODE_SOURCE_LABEL, + 'is_cli_session': True, + 'read_only': True, + }) + sessions.sort(key=lambda s: s.get('last_message_at') or s.get('updated_at') or 0, reverse=True) + return sessions + + +def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None) -> list: + """Return messages for one read-only Claude Code JSONL session.""" + sid = str(sid or '') + if not sid.startswith(f'{CLAUDE_CODE_SOURCE}_'): + return [] + for path in _iter_claude_code_jsonl_files(projects_dir) or []: + if _claude_code_session_id(path) != sid: + continue + messages, _summary_title, _first_ts, _last_ts = _parse_claude_code_jsonl(path) + return messages + return [] + + def get_cli_sessions() -> list: """Read CLI sessions from the agent's SQLite store and return them as dicts in a format the WebUI sidebar can render alongside local sessions. @@ -1240,6 +1466,10 @@ def get_cli_sessions() -> list: """ import os cli_sessions = [] + try: + cli_sessions.extend(get_claude_code_sessions()) + except Exception: + logger.debug("Claude Code session scan failed", exc_info=True) # Use the active WebUI profile's HERMES_HOME to find state.db. # The active profile is determined by what the user has selected in the UI @@ -1362,11 +1592,13 @@ def _cron_pid(): def get_cli_session_messages(sid) -> list: - """Read messages for a single CLI session from the SQLite store. + """Read messages for a single CLI/external-agent session. Returns a list of {role, content, timestamp} dicts. Returns empty list on any error. """ import os + if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'): + return get_claude_code_session_messages(sid) try: import sqlite3 except ImportError: diff --git a/api/routes.py b/api/routes.py index 92109eecff..a092cbdf22 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2122,6 +2122,7 @@ def handle_get(handler, parsed) -> bool: "raw_source": (cli_meta or {}).get("raw_source"), "session_source": (cli_meta or {}).get("session_source"), "source_label": (cli_meta or {}).get("source_label"), + "read_only": bool((cli_meta or {}).get("read_only")), "messages": msgs, "tool_calls": [], } @@ -2908,6 +2909,9 @@ def handle_post(handler, parsed) -> bool: return bad(handler, "session_id is required") if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid): return bad(handler, "Invalid session_id", 400) + cli_meta_for_delete = _lookup_cli_session_metadata(sid) + if cli_meta_for_delete.get("read_only"): + return bad(handler, "Read-only imported sessions cannot be deleted from WebUI", 400) is_messaging_session = _is_messaging_session_id(sid) # Delete from WebUI session store with LOCK: @@ -3509,6 +3513,8 @@ def handle_post(handler, parsed) -> bool: cli_meta = _lookup_cli_session_metadata(sid) if not cli_meta: return bad(handler, "Session not found", 404) + if cli_meta.get("read_only"): + return bad(handler, "Read-only imported sessions cannot be archived from WebUI", 400) if _is_messaging_session_record(cli_meta): s = Session( session_id=sid, @@ -6997,6 +7003,7 @@ def _handle_session_import_cli(handler, body): | { "messages": existing.messages, "is_cli_session": True, + "read_only": bool((cli_meta or {}).get("read_only")), }, "imported": False, }, @@ -7023,6 +7030,7 @@ def _handle_session_import_cli(handler, body): cli_thread_id = None cli_session_key = None cli_platform = None + cli_read_only = False for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") @@ -7040,6 +7048,7 @@ def _handle_session_import_cli(handler, body): cli_thread_id = cs.get("thread_id") cli_session_key = cs.get("session_key") cli_platform = cs.get("platform") + cli_read_only = bool(cs.get("read_only")) break # Use the CLI session title if available (e.g., cron job name), otherwise derive from messages @@ -7050,6 +7059,31 @@ def _handle_session_import_cli(handler, body): if is_cron_session(sid, cli_source_tag): cron_project_id = ensure_cron_project() + if cli_read_only: + session_payload = { + "session_id": sid, + "title": title, + "workspace": str(get_last_workspace()), + "model": model, + "message_count": len(msgs), + "created_at": created_at, + "updated_at": updated_at, + "last_message_at": updated_at or created_at, + "pinned": False, + "archived": False, + "project_id": None, + "profile": profile, + "is_cli_session": True, + "source_tag": cli_source_tag, + "raw_source": cli_raw_source or cli_source_tag, + "session_source": cli_session_source, + "source_label": cli_source_label, + "read_only": True, + "messages": msgs, + "tool_calls": [], + } + return j(handler, {"session": session_payload, "imported": False}) + s = import_cli_session( sid, title, diff --git a/docs/pr-media/674/claude-code-import-readonly.png b/docs/pr-media/674/claude-code-import-readonly.png new file mode 100644 index 0000000000..26bf7d7f25 Binary files /dev/null and b/docs/pr-media/674/claude-code-import-readonly.png differ diff --git a/static/messages.js b/static/messages.js index bc2ddee346..7360e73b67 100644 --- a/static/messages.js +++ b/static/messages.js @@ -110,6 +110,10 @@ async function send(){ } return; } + if(S.session&&(S.session.read_only||S.session.is_read_only)){ + if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); + return; + } // Slash command intercept -- local commands handled without agent round-trip. // We push the user message BEFORE running the handler for echo-worthy // commands so chat order is correct: some handlers (e.g. cmdHelp) push diff --git a/static/panels.js b/static/panels.js index a4f19bb51c..72a39deb77 100644 --- a/static/panels.js +++ b/static/panels.js @@ -31,10 +31,12 @@ function syncAppTitlebar() { const panel = (typeof _currentPanel === 'string' && _currentPanel) ? _currentPanel : 'chat'; let mainText = ''; let subText = ''; + let sourceLabel = ''; if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) { mainText = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled'); const vis = Array.isArray(S.messages) ? S.messages.filter(m => m && m.role && m.role !== 'tool') : []; if (typeof t === 'function') subText = t('n_messages', vis.length); + if (S.session.is_cli_session) sourceLabel = S.session.source_label || S.session.source_tag || S.session.raw_source || ''; } else { const key = APP_TITLEBAR_KEYS[panel]; mainText = key && typeof t === 'function' ? t(key) : (panel.charAt(0).toUpperCase() + panel.slice(1)); @@ -47,7 +49,17 @@ function syncAppTitlebar() { titleEl.textContent = mainText; if (subEl) { - if (subText) { subEl.textContent = subText; subEl.hidden = false; } + if (subText) { + subEl.textContent = subText; + if (sourceLabel) { + const badge = document.createElement('span'); + badge.className = 'topbar-source-badge'; + badge.textContent = sourceLabel + (S.session && S.session.read_only ? ' · read-only' : ''); + subEl.appendChild(document.createTextNode(' ')); + subEl.appendChild(badge); + } + subEl.hidden = false; + } else { subEl.textContent = ''; subEl.hidden = true; } } @@ -55,7 +67,7 @@ function syncAppTitlebar() { // as double-clicking a session title in the sidebar). Only active on the chat // panel when a session is open. titleEl.ondblclick = null; // remove any previous handler before adding a fresh one - if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) { + if (panel === 'chat' && typeof S !== 'undefined' && S && S.session && !(S.session.read_only || S.session.is_read_only)) { titleEl.ondblclick = (e) => { e.stopPropagation(); e.preventDefault(); diff --git a/static/sessions.js b/static/sessions.js index 2f307bb9bf..5f86dfb443 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -576,6 +576,14 @@ function _isMessagingSession(session) { return _MESSAGING_RAW_SOURCES.has(raw); } +function _isReadOnlySession(session) { + return !!(session && (session.read_only || session.is_read_only)); +} + +function _sourceKeyForSession(session) { + return (session && (session.raw_source || session.source_tag || session.source || '') || '').toLowerCase(); +} + function _normalizeMessageForCliImportComparison(message) { if (!message || typeof message !== 'object') return message; const clone = { ...message }; @@ -1256,6 +1264,7 @@ function _appendSessionDuplicateAction(menu, session){ } function _openSessionActionMenu(session, anchorEl){ + if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; } if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){ closeSessionActionMenu(); return; @@ -2070,7 +2079,14 @@ function renderSessionListFromCache(){ _rememberRenderedStreamingState(s, isStreaming); _rememberRenderedSessionSnapshot(s); const hasUnread=_hasUnreadForSession(s)&&!isActive; + const readOnly=_isReadOnlySession(s); el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':''); + if(s.is_cli_session){ + el.classList.add('cli-session'); + el.dataset.source=_getChannelLabel(s)||'CLI'; + el.dataset.sourceKey=_sourceKeyForSession(s)||'cli'; + } + if(readOnly) el.classList.add('read-only-session'); if(isActive&&S.session&&S.session._flash)delete S.session._flash; const rawTitle=s.title||'Untitled'; const tags=(rawTitle.match(/#[\w-]+/g)||[]); @@ -2080,7 +2096,7 @@ function renderSessionListFromCache(){ cleanTitle='Session'; } // Checkbox for batch select mode - if(_sessionSelectMode){ + if(_sessionSelectMode&&!readOnly){ const cbWrapper=document.createElement('label');cbWrapper.className='session-select-cb-wrapper'; const cb=document.createElement('input');cb.type='checkbox';cb.className='session-select-cb'; cb.dataset.sid=s.session_id;cb.checked=_selectedSessions.has(s.session_id); @@ -2115,7 +2131,7 @@ function renderSessionListFromCache(){ const title=document.createElement('span'); title.className='session-title'; title.textContent=cleanTitle||'Untitled'; - title.title='Double-click to rename'; + title.title=readOnly?'Read-only imported session':'Double-click to rename'; const tsMs=_sessionTimestampMs(s); const ts=document.createElement('span'); const hasAttentionState=isStreaming||hasUnread; @@ -2166,6 +2182,9 @@ function renderSessionListFromCache(){ metaBits.push(msgLabel); if(childCount>0) metaBits.push(t('session_meta_children', childCount)); if(s.model) metaBits.push(s.model); + const sourceLabel=_getChannelLabel(s); + if(s.is_cli_session&&sourceLabel) metaBits.push(sourceLabel); + if(readOnly) metaBits.push('read-only'); if(_showAllProfiles&&s.profile) metaBits.push(s.profile); const meta=document.createElement('div'); meta.className='session-meta'; @@ -2216,6 +2235,7 @@ function renderSessionListFromCache(){ // Rename: called directly when we confirm it's a double-click const startRename=()=>{ + if(_isReadOnlySession(s)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be renamed.',3000); return; } // Guard: prevent renaming if session is currently being loaded if (_loadingSessionId && _loadingSessionId !== s.session_id) return; @@ -2288,22 +2308,25 @@ function renderSessionListFromCache(){ state.setAttribute('aria-hidden','true'); el.appendChild(state); // Single trigger button that opens a shared dropdown menu - const actions=document.createElement('div'); - actions.className='session-actions'; - const menuBtn=document.createElement('button'); - menuBtn.type='button'; - menuBtn.className='session-actions-trigger'; - menuBtn.title='Conversation actions'; - menuBtn.setAttribute('aria-haspopup','menu'); - menuBtn.setAttribute('aria-label','Conversation actions'); - menuBtn.innerHTML=ICONS.more; - menuBtn.onclick=(e)=>{ - e.stopPropagation(); - e.preventDefault(); - _openSessionActionMenu(s, menuBtn); - }; - actions.appendChild(menuBtn); - el.appendChild(actions); + let actions=null; + if(!readOnly){ + actions=document.createElement('div'); + actions.className='session-actions'; + const menuBtn=document.createElement('button'); + menuBtn.type='button'; + menuBtn.className='session-actions-trigger'; + menuBtn.title='Conversation actions'; + menuBtn.setAttribute('aria-haspopup','menu'); + menuBtn.setAttribute('aria-label','Conversation actions'); + menuBtn.innerHTML=ICONS.more; + menuBtn.onclick=(e)=>{ + e.stopPropagation(); + e.preventDefault(); + _openSessionActionMenu(s, menuBtn); + }; + actions.appendChild(menuBtn); + el.appendChild(actions); + } // Use pointerup + manual double-tap detection instead of onclick/ondblclick. // onclick/ondblclick are unreliable on touch devices (iPad Safari especially): @@ -2338,9 +2361,9 @@ function renderSessionListFromCache(){ el.onpointerup=(e)=>{ if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click if(_renamingSid) return; - if(actions.contains(e.target)) return; + if(actions&&actions.contains(e.target)) return; if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session')) return; - if(_sessionSelectMode){e.stopPropagation();toggleSessionSelect(s.session_id);return;} + if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;} // If the pointer moved enough to be a drag, cancel any pending tap if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);return;} const now=Date.now(); @@ -2376,8 +2399,8 @@ function renderSessionListFromCache(){ el.ondblclick=(e)=>{ if(e.pointerType==='mouse' && e.button!==0) return; if(_renamingSid) return; - if(actions.contains(e.target)) return; - if(_sessionSelectMode){e.stopPropagation();toggleSessionSelect(s.session_id);return;} + if(actions&&actions.contains(e.target)) return; + if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;} // Guard: prevent renaming if session is currently being loaded if (_loadingSessionId && _loadingSessionId !== s.session_id) return; startRename(); diff --git a/static/style.css b/static/style.css index e428982e90..809d931f3f 100644 --- a/static/style.css +++ b/static/style.css @@ -1084,8 +1084,10 @@ .panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;gap:6px;overflow:hidden;} .panel-header > span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex-shrink:2;} .git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;flex-shrink:3;overflow:hidden;min-width:0;} + .topbar-source-badge{display:inline-flex;align-items:center;margin-left:6px;padding:2px 7px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);font-size:10px;font-weight:700;letter-spacing:.02em;vertical-align:middle;} .git-badge.dirty{color:var(--accent-text);background:var(--accent-bg);} .panel-actions{display:flex;gap:4px;flex-shrink:0;margin-left:auto;} + /* Crisp display:none at narrow widths so the header doesn't show a sliver of an ellipsised label or git badge — icons survive longest. */ @container rightpanel (max-width: 220px){ @@ -2575,19 +2577,24 @@ main.main.showing-profiles > #mainProfiles{display:flex;} flex-shrink: 0; pointer-events: none; /* don't block clicks on session-actions beneath */ } -.session-item.cli-session:hover::after { +.session-item.cli-session:not(.read-only-session):hover::after { display: none; /* hide badge on hover so the session menu trigger stays clear */ } +.session-item.cli-session.read-only-session:hover::after { + opacity: .75; +} .session-item.cli-session.menu-open::after { display: none; } /* Source-specific colors for gateway sessions */ -.session-item.cli-session[data-source="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); } -.session-item.cli-session[data-source="telegram"]::after { color: rgba(0, 136, 204, 0.55); } -.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; } -.session-item.cli-session[data-source="discord"]::after { color: #5865F2; } -.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; } -.session-item.cli-session[data-source="slack"]::after { color: #4A154B; } +.session-item.cli-session[data-source-key="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); } +.session-item.cli-session[data-source-key="telegram"]::after { color: rgba(0, 136, 204, 0.55); } +.session-item.cli-session[data-source-key="discord"] { border-left-color: #5865F2; } +.session-item.cli-session[data-source-key="discord"]::after { color: #5865F2; } +.session-item.cli-session[data-source-key="slack"] { border-left-color: #4A154B; } +.session-item.cli-session[data-source-key="slack"]::after { color: #4A154B; } +.session-item.cli-session[data-source-key="claude_code"] { border-left-color: rgba(217, 119, 6, 0.65); } +.session-item.cli-session[data-source-key="claude_code"]::after { color: rgba(217, 119, 6, 0.85); } /* ═══════════════════════════════════════════════════════════════════ Messages redesign — additive overrides for the transcript area. diff --git a/static/ui.js b/static/ui.js index c45ab3da85..f342df099c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3092,7 +3092,19 @@ function syncTopbar(){ const _topbarTitle=$('topbarTitle');if(_topbarTitle)_topbarTitle.textContent=sessionTitle; document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes'); const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); - const _topbarMeta=$('topbarMeta');if(_topbarMeta)_topbarMeta.textContent=t('n_messages',vis.length); + const _topbarMeta=$('topbarMeta'); + if(_topbarMeta){ + const sourceLabel=(S.session&&S.session.is_cli_session&&(S.session.source_label||S.session.source_tag||S.session.raw_source))||''; + const metaText=t('n_messages',vis.length); + _topbarMeta.textContent=metaText; + if(sourceLabel){ + const badge=document.createElement('span'); + badge.className='topbar-source-badge'; + badge.textContent=sourceLabel+(S.session.read_only?' · read-only':''); + _topbarMeta.appendChild(document.createTextNode(' ')); + _topbarMeta.appendChild(badge); + } + } if(typeof syncAppTitlebar==='function') syncAppTitlebar(); // If a profile switch just happened, apply its model rather than the session's stale value. // S._pendingProfileModel is set by switchToProfile() and cleared here after one application. diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py new file mode 100644 index 0000000000..54e0859f79 --- /dev/null +++ b/tests/test_claude_code_session_import.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8") + + +def _claude_fixture_rows() -> list[dict]: + return [ + {"summary": "Claude Code import QA"}, + {"timestamp": "2026-04-18T12:00:01Z", "message": {"role": "user", "content": [{"type": "text", "text": "Can Hermes show this Claude Code history read-only?"}]}}, + {"timestamp": "2026-04-18T12:00:02Z", "message": {"role": "assistant", "content": "Yes — it appears with a Claude Code source badge."}}, + "not a dict", + {"not_json_message": True}, + ] + + +def test_default_claude_code_scan_is_disabled_inside_test_state(monkeypatch, tmp_path): + """Test runs must not accidentally scan Michael's real ~/.claude/projects.""" + import api.models as models + + monkeypatch.delenv("HERMES_WEBUI_CLAUDE_PROJECTS_DIR", raising=False) + monkeypatch.setenv("HERMES_WEBUI_TEST_STATE_DIR", str(tmp_path / "state")) + + assert models._default_claude_code_projects_dir() is None + assert models.get_claude_code_sessions() == [] + + +def test_get_claude_code_sessions_reads_fixture_jsonl_without_real_home(tmp_path): + import api.models as models + + projects_dir = tmp_path / "claude" / "projects" + fixture = projects_dir / "project-a" / "session.jsonl" + _write_jsonl(fixture, _claude_fixture_rows()) + + sessions = models.get_claude_code_sessions(projects_dir=projects_dir) + + assert len(sessions) == 1 + session = sessions[0] + assert session["session_id"].startswith("claude_code_") + assert session["title"] == "Claude Code import QA" + assert session["model"] == "claude-code" + assert session["message_count"] == 2 + assert session["source_tag"] == "claude_code" + assert session["raw_source"] == "claude_code" + assert session["session_source"] == "external_agent" + assert session["source_label"] == "Claude Code" + assert session["is_cli_session"] is True + assert session["read_only"] is True + + messages = models.get_claude_code_session_messages(session["session_id"], projects_dir=projects_dir) + assert messages == [ + {"role": "user", "content": "Can Hermes show this Claude Code history read-only?", "timestamp": 1776513601.0}, + {"role": "assistant", "content": "Yes — it appears with a Claude Code source badge.", "timestamp": 1776513602.0}, + ] + + +def test_claude_code_scan_skips_symlinks_and_oversized_files(tmp_path): + import api.models as models + + projects_dir = tmp_path / "claude" / "projects" + valid = projects_dir / "project-a" / "valid.jsonl" + _write_jsonl(valid, [{"message": {"role": "user", "content": "valid import"}}]) + oversized = projects_dir / "project-a" / "oversized.jsonl" + oversized.write_text("x" * 1024, encoding="utf-8") + + outside = tmp_path / "outside" + outside.mkdir() + _write_jsonl(outside / "leaked.jsonl", [{"message": {"role": "user", "content": "do not import"}}]) + symlink_project = projects_dir / "symlink-project" + symlink_project.symlink_to(outside, target_is_directory=True) + + root_link = tmp_path / "root-link" + root_link.symlink_to(projects_dir, target_is_directory=True) + + sessions = models.get_claude_code_sessions(projects_dir=projects_dir, max_file_bytes=512) + + assert [session["title"] for session in sessions] == ["valid import"] + assert models.get_claude_code_sessions(projects_dir=root_link) == [] + + +def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, tmp_path): + import api.routes as routes + + sid = "claude_code_fixture" + messages = [{"role": "user", "content": "history"}] + meta = { + "session_id": sid, + "title": "Claude Code fixture", + "model": "claude-code", + "created_at": 10.0, + "updated_at": 20.0, + "source_tag": "claude_code", + "raw_source": "claude_code", + "session_source": "external_agent", + "source_label": "Claude Code", + "is_cli_session": True, + "read_only": True, + } + + monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None)) + monkeypatch.setattr(routes, "require", lambda body, *keys: None) + monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status}) + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) + monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: messages if _sid == sid else []) + monkeypatch.setattr(routes, "get_cli_sessions", lambda: [meta]) + monkeypatch.setattr(routes, "get_last_workspace", lambda: tmp_path / "workspace") + monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("read-only import must not persist"))) + + response = routes._handle_session_import_cli(object(), {"session_id": sid}) + + assert response["imported"] is False + session = response["session"] + assert session["session_id"] == sid + assert session["title"] == "Claude Code fixture" + assert session["model"] == "claude-code" + assert session["messages"] == messages + assert session["read_only"] is True + assert session["source_tag"] == "claude_code" + assert session["raw_source"] == "claude_code" + assert session["session_source"] == "external_agent" + assert session["source_label"] == "Claude Code" + assert session["is_cli_session"] is True + + +def test_read_only_source_badge_ui_guards_are_present(): + sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + messages_js = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") + ui_js = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") + panels_js = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") + style_css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + routes_py = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") + + assert "function _isReadOnlySession" in sessions_js + assert "read-only-session" in sessions_js + assert "if(!readOnly)" in sessions_js + assert "Read-only imported sessions cannot be renamed" in sessions_js + assert "Read-only imported sessions cannot be modified" in sessions_js + assert "S.session.read_only||S.session.is_read_only" in messages_js + assert "topbar-source-badge" in ui_js + assert " · read-only" in ui_js + assert "topbar-source-badge" in panels_js + assert "S.session.read_only || S.session.is_read_only" in panels_js + assert 'data-source-key="claude_code"' in style_css + assert ".session-item.cli-session.read-only-session:hover::after" in style_css + assert "Read-only imported sessions cannot be deleted" in routes_py + assert "Read-only imported sessions cannot be archived" in routes_py