Add worktree-backed session creation

This commit is contained in:
Frank Song committed 2026-05-11 12:12:40 +08:00
1 parent 9c9d65a41a
commit 186453ea0e
12 files changed
+433 -7

No files matched your search

+2
View File
@@ -2,6 +2,8 @@
## [Unreleased]
- Added worktree-backed conversation creation for development sessions: the workspace menu can now create a new conversation in a Hermes Agent git worktree, persist the worktree path/branch/repo metadata on the session, and show a sidebar worktree indicator without adding automatic cleanup yet (#1955).
## [v0.51.42] — 2026-05-11 — Release R (5-PR contributor batch — session recovery state.db reconciliation + RFC convention + MEDIA_ALLOWED_ROOTS + Slack cron delivery)
### Added
+30 -3
View File
@@ -335,6 +335,10 @@ class Session:
gateway_routing=None, gateway_routing_history=None,
llm_title_generated: bool=False,
parent_session_id: str=None,
worktree_path=None,
worktree_branch=None,
worktree_repo_root=None,
worktree_created_at=None,
enabled_toolsets=None,
composer_draft=None,
**kwargs):
@@ -370,6 +374,10 @@ class Session:
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
self.llm_title_generated = bool(llm_title_generated)
self.parent_session_id = parent_session_id
self.worktree_path = str(Path(worktree_path).expanduser().resolve()) if worktree_path else None
self.worktree_branch = str(worktree_branch) if worktree_branch else None
self.worktree_repo_root = str(Path(worktree_repo_root).expanduser().resolve()) if worktree_repo_root else None
self.worktree_created_at = worktree_created_at
self.is_cli_session = bool(kwargs.get('is_cli_session', False))
self.source_tag = kwargs.get('source_tag')
self.raw_source = kwargs.get('raw_source')
@@ -417,6 +425,7 @@ class Session:
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
'enabled_toolsets', 'composer_draft',
]
@@ -584,6 +593,12 @@ class Session:
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
**({
'worktree_path': self.worktree_path,
'worktree_branch': self.worktree_branch,
'worktree_repo_root': self.worktree_repo_root,
'worktree_created_at': self.worktree_created_at,
} if self.worktree_path else {}),
'user_message_count': sum(
1 for message in self.messages if _message_role(message) == 'user'
) if isinstance(self.messages, list) else 0,
@@ -896,7 +911,7 @@ def get_session(sid, metadata_only=False):
return s
raise KeyError(sid)
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None):
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None):
"""Create a new in-memory session.
The session lives in the SESSIONS dict only — no disk write happens until
@@ -911,7 +926,9 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
Crash-safety: if the process exits between session creation and first
message, the session is lost. Since it had no messages, there is
nothing to lose.
nothing to lose. Worktree-backed sessions are the exception: they are
saved immediately because creating the session also creates real
filesystem state that must remain discoverable after restart.
*profile* — when supplied by the caller (e.g. from the request body sent
by the active browser tab), it is used directly so that concurrent clients
@@ -927,18 +944,26 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
wt = worktree_info if isinstance(worktree_info, dict) else None
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
s = Session(
workspace=workspace or get_last_workspace(),
workspace=workspace_path or get_last_workspace(),
model=effective_model,
model_provider=model_provider,
profile=profile,
project_id=project_id,
worktree_path=wt.get('path') if wt else None,
worktree_branch=wt.get('branch') if wt else None,
worktree_repo_root=wt.get('repo_root') if wt else None,
worktree_created_at=wt.get('created_at') if wt else None,
)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False)
if wt:
s.save()
return s
def _hide_from_default_sidebar(session: dict) -> bool:
@@ -1042,6 +1067,7 @@ def all_sessions(diag=None):
and s.get('message_count', 0) == 0
and not s.get('active_stream_id')
and not s.get('has_pending_user_message')
and not s.get('worktree_path')
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
# Backfill: sessions created before Sprint 22 have no profile tag.
@@ -1077,6 +1103,7 @@ def all_sessions(diag=None):
and len(s.messages) == 0
and not s.active_stream_id
and not s.pending_user_message
and not getattr(s, 'worktree_path', None)
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
for s in result:
+17
View File
@@ -3849,6 +3849,22 @@ def handle_post(handler, parsed) -> bool:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
except ValueError as e:
return bad(handler, str(e))
worktree_info = None
worktree_requested = (
body.get("worktree") is True
or str(body.get("worktree")).strip().lower() in {"1", "true", "yes", "on"}
)
if worktree_requested:
try:
from api.worktrees import create_worktree_for_workspace
base_workspace = workspace or str(resolve_trusted_workspace(get_last_workspace()))
worktree_info = create_worktree_for_workspace(base_workspace)
workspace = worktree_info["path"]
except ValueError as e:
return bad(handler, str(e), status=400)
except Exception as e:
logger.exception("failed to create worktree-backed session")
return bad(handler, f"Failed to create worktree: {e}", status=500)
model, model_provider = _session_model_state_from_request(
body.get("model"),
body.get("model_provider"),
@@ -3861,6 +3877,7 @@ def handle_post(handler, parsed) -> bool:
model_provider=model_provider,
profile=body.get("profile") or None,
project_id=body.get("project_id") or None,
worktree_info=worktree_info,
)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
+73
View File
@@ -0,0 +1,73 @@
"""Helpers for WebUI-managed Hermes Agent git worktrees."""
from __future__ import annotations
import subprocess
import time
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
def find_git_repo_root(workspace: str | Path) -> Path:
"""Return the enclosing git repo root for *workspace*.
Use git itself instead of checking ``workspace/.git`` so nested workspaces
and linked git worktrees are both handled correctly.
"""
ws = Path(workspace).expanduser().resolve()
if not ws.is_dir():
raise ValueError("Workspace path does not exist or is not a directory")
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=ws,
text=True,
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError("Workspace is not inside a git repository") from exc
if result.returncode != 0:
raise ValueError("Workspace is not inside a git repository")
root = result.stdout.strip()
if not root:
raise ValueError("Workspace is not inside a git repository")
return Path(root).expanduser().resolve()
def _setup_agent_worktree(repo_root: str) -> dict:
try:
import api.config # noqa: F401 # ensure Hermes Agent dir is on sys.path
from cli import _setup_worktree
except Exception as exc:
raise RuntimeError("Hermes Agent worktree helper is unavailable") from exc
output = StringIO()
with redirect_stdout(output), redirect_stderr(output):
info = _setup_worktree(repo_root)
emitted = output.getvalue().strip()
if emitted:
logger.debug("Hermes Agent worktree helper output: %s", emitted)
if not info:
raise RuntimeError("Hermes Agent failed to create a git worktree")
return info
def create_worktree_for_workspace(workspace: str | Path) -> dict:
repo_root = find_git_repo_root(workspace)
info = _setup_agent_worktree(str(repo_root))
path = info.get("path")
branch = info.get("branch")
if not path or not branch:
raise RuntimeError("Hermes Agent returned incomplete worktree metadata")
return {
"path": str(Path(path).expanduser().resolve()),
"branch": str(branch),
"repo_root": str(Path(info.get("repo_root") or repo_root).expanduser().resolve()),
"created_at": time.time(),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+40
View File
@@ -151,6 +151,11 @@ const LOCALES = {
model_group_configured: 'Configured',
ws_search_placeholder: 'Search workspaces…',
ws_no_results: 'No workspaces found',
workspace_new_worktree_conversation: 'New conversation in worktree',
workspace_new_worktree_conversation_meta: 'Create an isolated git worktree for this workspace.',
workspace_worktree_created: 'Worktree conversation created',
workspace_worktree_failed: 'Worktree creation failed: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: 'Applies to this conversation from your next message.',
model_scope_toast: 'Applies to this conversation from your next message.',
// commands.js
@@ -1236,6 +1241,11 @@ const LOCALES = {
model_group_configured: '設定済み',
ws_search_placeholder: 'ワークスペースを検索…',
ws_no_results: 'ワークスペースが見つかりません',
workspace_new_worktree_conversation: 'worktree で新しい会話',
workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。',
workspace_worktree_created: 'worktree 会話を作成しました',
workspace_worktree_failed: 'worktree の作成に失敗しました: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: '次回のメッセージからこの会話に適用されます。',
model_scope_toast: '次回のメッセージからこの会話に適用されます。',
// commands.js
@@ -2383,6 +2393,11 @@ const LOCALES = {
model_group_configured: 'Настроенные',
ws_search_placeholder: 'Поиск рабочих пространств…',
ws_no_results: 'Рабочие пространства не найдены',
workspace_new_worktree_conversation: 'Новый разговор в worktree',
workspace_new_worktree_conversation_meta: 'Создать изолированный git worktree для этого рабочего пространства.',
workspace_worktree_created: 'Разговор в worktree создан',
workspace_worktree_failed: 'Не удалось создать worktree: ',
session_worktree_badge: 'Worktree',
model_search_placeholder: 'Поиск моделей…',
model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.',
session_toolsets: 'Session Toolsets', // TODO: translate
@@ -3320,6 +3335,11 @@ const LOCALES = {
model_group_configured: 'Configurados',
ws_search_placeholder: 'Buscar espacios de trabajo…',
ws_no_results: 'No se encontraron espacios de trabajo',
workspace_new_worktree_conversation: 'Nueva conversación en worktree',
workspace_new_worktree_conversation_meta: 'Crear un git worktree aislado para este espacio de trabajo.',
workspace_worktree_created: 'Conversación en worktree creada',
workspace_worktree_failed: 'Error al crear worktree: ',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session Toolsets', // TODO: translate
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
session_toolsets_global: 'Global (default)', // TODO: translate
@@ -4882,6 +4902,11 @@ const LOCALES = {
model_group_configured: 'Konfiguriert',
ws_search_placeholder: 'Arbeitsbereiche suchen…',
ws_no_results: 'Keine Arbeitsbereiche gefunden',
workspace_new_worktree_conversation: 'Neue Unterhaltung in Worktree',
workspace_new_worktree_conversation_meta: 'Erstellt einen isolierten git worktree für diesen Arbeitsbereich.',
workspace_worktree_created: 'Worktree-Unterhaltung erstellt',
workspace_worktree_failed: 'Worktree-Erstellung fehlgeschlagen: ',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session Toolsets', // TODO: translate
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
session_toolsets_global: 'Global (default)', // TODO: translate
@@ -5358,6 +5383,11 @@ const LOCALES = {
model_group_configured: '已配置',
ws_search_placeholder: '搜索工作区…',
ws_no_results: '未找到工作区',
workspace_new_worktree_conversation: '在 worktree 中新建对话',
workspace_new_worktree_conversation_meta: '为此工作区创建隔离的 git worktree。',
workspace_worktree_created: '已创建 worktree 对话',
workspace_worktree_failed: 'Worktree 创建失败:',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session 工具集',
session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)',
session_toolsets_global: '全局(默认)',
@@ -7451,6 +7481,11 @@ const LOCALES = {
model_group_configured: 'Configurados',
ws_search_placeholder: 'Buscar espaços de trabalho…',
ws_no_results: 'Nenhum espaço de trabalho encontrado',
workspace_new_worktree_conversation: 'Nova conversa em worktree',
workspace_new_worktree_conversation_meta: 'Cria um git worktree isolado para este espaço de trabalho.',
workspace_worktree_created: 'Conversa em worktree criada',
workspace_worktree_failed: 'Falha ao criar worktree: ',
session_worktree_badge: 'Worktree',
// commands.js
cmd_clear: 'Limpar mensagens da conversa',
cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])',
@@ -8407,6 +8442,11 @@ const LOCALES = {
model_group_configured: '구성됨',
ws_search_placeholder: '워크스페이스 검색…',
ws_no_results: '워크스페이스를 찾을 수 없습니다',
workspace_new_worktree_conversation: 'worktree에서 새 대화',
workspace_new_worktree_conversation_meta: '이 워크스페이스용 격리된 git worktree를 만듭니다.',
workspace_worktree_created: 'worktree 대화가 생성되었습니다',
workspace_worktree_failed: 'worktree 생성 실패: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.',
model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.',
// commands.js
+18
View File
@@ -3673,6 +3673,24 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
// ── Footer actions ────────────────────────────────────────────────────────
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
t('workspace_new_worktree_conversation'),
t('workspace_new_worktree_conversation_meta'),
li('git-branch',12),
async()=>{
closeWsDropdown();
try{
await newSession(false,{worktree:true});
await renderSessionList();
const msg=$('msg');
if(msg)msg.focus();
showToast(t('workspace_worktree_created'));
}catch(e){
showToast(t('workspace_worktree_failed')+(e&&e.message?e.message:e),'error');
}
}
));
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
t('workspace_choose_path'),
t('workspace_choose_path_meta'),
+10 -1
View File
@@ -340,7 +340,7 @@ function _markPollingCompletionUnreadTransitions(sessions) {
}
}
async function newSession(flash){
async function newSession(flash, options={}){
updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
@@ -371,6 +371,7 @@ async function newSession(flash){
workspace:inheritWs,
profile:S.activeProfile||'default',
};
if(options&&options.worktree) reqBody.worktree=true;
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
S.session=data.session;S.messages=data.session.messages||[];
@@ -2581,6 +2582,14 @@ function renderSessionListFromCache(){
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
if(s.worktree_path){
const wtInd=document.createElement('span');
wtInd.className='session-worktree-indicator';
wtInd.innerHTML=li('git-branch',12);
const wtLabel=(typeof t==='function'?t('session_worktree_badge'):'Worktree');
wtInd.title=`${wtLabel}: ${s.worktree_branch||s.worktree_path}`;
titleRow.appendChild(wtInd);
}
// Parent session indicator for forked/branched sessions (#465)
if(s.parent_session_id){
const branchInd=document.createElement('span');
+12 -3
View File
@@ -2641,7 +2641,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
.session-pin-indicator svg{width:10px;height:10px;}
/* ── Fork lineage indicator (inline, subtle until row focus/hover) ── */
.session-branch-indicator{
.session-branch-indicator,
.session-worktree-indicator{
flex-shrink:0;
width:12px;
height:12px;
@@ -2654,14 +2655,22 @@ main.main.showing-logs > #mainLogs{display:flex;}
pointer-events:none;
transition:opacity .15s ease,color .15s ease;
}
.session-branch-indicator svg{width:12px;height:12px;}
.session-branch-indicator svg,
.session-worktree-indicator svg{width:12px;height:12px;}
.session-item:hover .session-branch-indicator,
.session-item:hover .session-worktree-indicator,
.session-item:focus-within .session-branch-indicator,
.session-item:focus-within .session-worktree-indicator,
.session-item.menu-open .session-branch-indicator{
opacity:.85;
color:var(--text);
}
.session-item.active .session-branch-indicator{color:var(--accent-text);}
.session-item.menu-open .session-worktree-indicator{
opacity:.85;
color:var(--text);
}
.session-item.active .session-branch-indicator,
.session-item.active .session-worktree-indicator{color:var(--accent-text);}
/* ── Cron alert badge ── */
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
+187
View File
@@ -0,0 +1,187 @@
import json
import subprocess
import time
from types import SimpleNamespace
import pytest
import api.models as models
from api.models import SESSIONS, Session, new_session
@pytest.fixture(autouse=True)
def _isolate_sessions(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
SESSIONS.clear()
yield session_dir
SESSIONS.clear()
def test_worktree_metadata_round_trips_through_session_file(_isolate_sessions):
s = Session(
session_id="worktree001",
workspace=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
worktree_path=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
worktree_branch="hermes/hermes-1234",
worktree_repo_root=str(_isolate_sessions.parent / "repo"),
worktree_created_at=123.5,
)
s.save()
raw = json.loads(s.path.read_text(encoding="utf-8"))
assert raw["worktree_path"].endswith(".worktrees/hermes-1234")
assert raw["worktree_branch"] == "hermes/hermes-1234"
assert raw["worktree_repo_root"].endswith("repo")
assert raw["worktree_created_at"] == 123.5
loaded = Session.load("worktree001")
assert loaded.worktree_path == s.worktree_path
assert loaded.worktree_branch == "hermes/hermes-1234"
assert loaded.worktree_repo_root == s.worktree_repo_root
assert loaded.worktree_created_at == 123.5
assert loaded.compact()["worktree_branch"] == "hermes/hermes-1234"
def test_new_session_with_worktree_info_persists_immediately(_isolate_sessions):
repo = _isolate_sessions.parent / "repo"
worktree = repo / ".worktrees" / "hermes-abcd1234"
worktree.mkdir(parents=True)
s = new_session(
workspace=str(worktree),
worktree_info={
"path": str(worktree),
"branch": "hermes/hermes-abcd1234",
"repo_root": str(repo),
"created_at": 456.0,
},
)
assert s.path.exists(), (
"worktree-backed sessions must be persisted at creation time so the "
"real filesystem worktree is not orphaned by a browser/server restart"
)
assert s.worktree_path == str(worktree.resolve())
assert s.worktree_branch == "hermes/hermes-abcd1234"
assert s.worktree_repo_root == str(repo.resolve())
assert s.worktree_created_at == 456.0
def test_empty_worktree_session_remains_visible_in_sidebar(_isolate_sessions):
repo = _isolate_sessions.parent / "repo"
worktree = repo / ".worktrees" / "hermes-visible"
worktree.mkdir(parents=True)
s = new_session(
workspace=str(worktree),
worktree_info={
"path": str(worktree),
"branch": "hermes/hermes-visible",
"repo_root": str(repo),
"created_at": 789.0,
},
)
ids = {row["session_id"] for row in models.all_sessions()}
assert s.session_id in ids, (
"worktree-backed sessions represent real filesystem state immediately "
"and must survive the empty-session sidebar filter"
)
def test_find_git_repo_root_uses_git_from_nested_workspace(tmp_path):
from api.worktrees import find_git_repo_root
repo = tmp_path / "repo"
nested = repo / "apps" / "web"
nested.mkdir(parents=True)
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
assert find_git_repo_root(nested) == repo.resolve()
def test_find_git_repo_root_rejects_non_git_workspace(tmp_path):
from api.worktrees import find_git_repo_root
with pytest.raises(ValueError, match="not inside a git repository"):
find_git_repo_root(tmp_path)
def test_create_worktree_for_workspace_calls_agent_setup_with_repo_root(tmp_path, monkeypatch):
import api.worktrees as worktrees
repo = tmp_path / "repo"
nested = repo / "src"
nested.mkdir(parents=True)
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
seen = {}
def fake_setup(repo_root):
seen["repo_root"] = repo_root
return {
"path": str(repo / ".worktrees" / "hermes-test"),
"branch": "hermes/hermes-test",
"repo_root": str(repo),
}
monkeypatch.setattr(worktrees, "_setup_agent_worktree", fake_setup)
now = time.time()
info = worktrees.create_worktree_for_workspace(nested)
assert seen["repo_root"] == str(repo.resolve())
assert info["path"].endswith(".worktrees/hermes-test")
assert info["branch"] == "hermes/hermes-test"
assert info["repo_root"] == str(repo.resolve())
assert info["created_at"] >= now
def test_session_new_route_creates_worktree_backed_session(tmp_path, monkeypatch):
import api.routes as routes
import api.worktrees as worktrees
repo = tmp_path / "repo"
worktree = repo / ".worktrees" / "hermes-route"
repo.mkdir()
worktree.mkdir(parents=True)
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(
routes,
"read_body",
lambda handler: {
"workspace": str(repo),
"worktree": True,
"profile": "default",
},
)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda raw: repo if raw == str(repo) else raw)
monkeypatch.setattr(
worktrees,
"create_worktree_for_workspace",
lambda workspace: {
"path": str(worktree),
"branch": "hermes/hermes-route",
"repo_root": str(repo),
"created_at": 321.0,
},
)
captured = {}
monkeypatch.setattr(
routes,
"j",
lambda handler, payload, status=200, extra_headers=None: captured.update(
payload=payload,
status=status,
) or True,
)
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/new")) is True
assert captured["status"] == 200
session = captured["payload"]["session"]
assert session["workspace"] == str(worktree.resolve())
assert session["worktree_path"] == str(worktree.resolve())
assert session["worktree_branch"] == "hermes/hermes-route"
@@ -0,0 +1,44 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(path):
return (ROOT / path).read_text(encoding="utf-8")
def test_session_new_route_accepts_worktree_flag_and_uses_worktree_info():
src = read("api/routes.py")
assert "create_worktree_for_workspace" in src
assert 'body.get("worktree")' in src or "body.get('worktree')" in src
assert "worktree_info=" in src
def test_new_session_request_can_include_worktree_flag():
src = read("static/sessions.js")
assert "async function newSession(flash, options={})" in src
assert "reqBody.worktree=true" in src
def test_workspace_dropdown_exposes_new_worktree_conversation_action():
src = read("static/panels.js")
assert "workspace_new_worktree_conversation" in src
assert "workspace_new_worktree_conversation_meta" in src
assert "newSession(false,{worktree:true})" in src
assert "li('git-branch',12)" in src
def test_session_sidebar_renders_worktree_indicator():
src = read("static/sessions.js")
assert "session-worktree-indicator" in src
assert "s.worktree_path" in src
assert "s.worktree_branch" in src
def test_worktree_indicator_styles_and_i18n_exist():
css = read("static/style.css")
i18n = read("static/i18n.js")
assert ".session-worktree-indicator" in css
assert "workspace_new_worktree_conversation" in i18n
assert "session_worktree_badge" in i18n