Clarify worktree session archive/delete semantics

(cherry picked from commit f5c8fb58d1)
This commit is contained in:
Frank Song
2026-05-11 15:58:04 +08:00
committed by nesquena-hermes
parent de982d40a0
commit 2da4f108c5
10 changed files with 291 additions and 11 deletions
+31 -2
View File
@@ -146,6 +146,34 @@ def _active_skill_search_dirs(skills_dir: Path) -> list[Path]:
return [p for p in dirs if p.exists()]
def _worktree_retained_payload(session) -> dict:
"""Return explicit no-cleanup metadata for worktree-backed session actions."""
worktree_path = getattr(session, "worktree_path", None) if session else None
if not worktree_path:
return {}
payload = {
"worktree_retained": True,
"worktree_path": worktree_path,
}
worktree_branch = getattr(session, "worktree_branch", None)
worktree_repo_root = getattr(session, "worktree_repo_root", None)
if worktree_branch:
payload["worktree_branch"] = worktree_branch
if worktree_repo_root:
payload["worktree_repo_root"] = worktree_repo_root
return payload
def _worktree_retained_payload_for_session_id(sid: str) -> dict:
try:
return _worktree_retained_payload(get_session(sid, metadata_only=True))
except KeyError:
return {}
except Exception:
logger.debug("Failed to read worktree metadata for deleted session %s", sid)
return {}
def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict:
"""List skills using an explicit local skills directory.
@@ -4219,6 +4247,7 @@ def handle_post(handler, parsed) -> bool:
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)
worktree_retained = _worktree_retained_payload_for_session_id(sid)
# Delete from WebUI session store
with LOCK:
SESSIONS.pop(sid, None)
@@ -4257,7 +4286,7 @@ def handle_post(handler, parsed) -> bool:
delete_cli_session(sid)
except Exception:
logger.debug("Failed to delete CLI session %s", sid)
return j(handler, {"ok": True})
return j(handler, {"ok": True, **worktree_retained})
if parsed.path == "/api/session/clear":
try:
@@ -4894,7 +4923,7 @@ def handle_post(handler, parsed) -> bool:
with _get_session_agent_lock(sid):
s.archived = bool(body.get("archived", True))
s.save(touch_updated_at=False)
return j(handler, {"ok": True, "session": s.compact()})
return j(handler, {"ok": True, "session": s.compact(), **_worktree_retained_payload(s)})
# ── Session move to project (POST) ──
if parsed.path == "/api/session/move":
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+81
View File
@@ -411,8 +411,10 @@ const LOCALES = {
session_archive: 'Archive conversation',
session_restore: 'Restore conversation',
session_archive_desc: 'Hide this conversation until archived is shown',
session_archive_worktree_desc: 'Hide this conversation; keep its worktree on disk',
session_restore_desc: 'Bring this conversation back into the main list',
session_archived: 'Session archived',
session_archived_worktree: 'Session archived. Worktree remains on disk.',
session_restored: 'Session restored',
session_archive_failed: 'Archive failed: ',
session_duplicate: 'Duplicate conversation',
@@ -423,6 +425,11 @@ const LOCALES = {
session_stop_response_desc: 'Cancel the running response for this conversation',
session_delete: 'Delete conversation',
session_delete_desc: 'Permanently remove this conversation',
session_delete_confirm: 'Delete this conversation?',
session_delete_worktree_desc: 'Delete only the WebUI conversation; keep the worktree on disk',
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
session_deleted: 'Conversation deleted',
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
session_select_mode: 'Select',
session_select_mode_desc: 'Select conversations to batch manage',
session_select_all: 'Select all',
@@ -433,6 +440,8 @@ const LOCALES = {
session_batch_move: 'Move to project',
session_batch_delete_confirm: 'Delete {0} conversations?',
session_batch_archive_confirm: 'Archive {0} conversations?',
session_batch_delete_worktree_confirm: 'Delete {0} conversations? {1} worktree-backed conversation(s) will leave their worktree directories on disk.',
session_batch_archive_worktree_confirm: 'Archive {0} conversations? {1} worktree-backed conversation(s) will keep their worktree directories on disk.',
session_no_selection: 'No conversations selected',
// settings panel
settings_heading_title: 'Control Center',
@@ -2604,8 +2613,10 @@ const LOCALES = {
session_archive: '会話をアーカイブ',
session_restore: '会話を復元',
session_archive_desc: 'アーカイブを表示するまでこの会話を非表示にする',
session_archive_worktree_desc: 'この会話を非表示にし、worktree はディスク上に残します',
session_restore_desc: 'この会話をメイン一覧に戻す',
session_archived: 'セッションをアーカイブしました',
session_archived_worktree: 'セッションをアーカイブしました。Worktree はディスク上に残ります。',
session_restored: 'セッションを復元しました',
session_archive_failed: 'アーカイブ失敗: ',
session_duplicate: '会話を複製',
@@ -2616,6 +2627,11 @@ const LOCALES = {
session_stop_response_desc: 'この会話の実行中の応答をキャンセルします',
session_delete: '会話を削除',
session_delete_desc: 'この会話を完全に削除',
session_delete_confirm: 'この会話を削除しますか?',
session_delete_worktree_desc: 'WebUI の会話だけを削除し、worktree はディスク上に残します',
session_delete_worktree_confirm: (path) => `この会話を削除しますか? ${path} の worktree はディスク上に残ります。`,
session_deleted: '会話を削除しました',
session_deleted_worktree: '会話を削除しました。Worktree はディスク上に残ります。',
session_select_mode: '選択',
session_select_mode_desc: '会話を選択して一括管理',
session_select_all: 'すべて選択',
@@ -2626,6 +2642,8 @@ const LOCALES = {
session_batch_move: 'プロジェクトへ移動',
session_batch_delete_confirm: '{0} 件の会話を削除しますか?',
session_batch_archive_confirm: '{0} 件の会話をアーカイブしますか?',
session_batch_delete_worktree_confirm: '{0} 件の会話を削除しますか? {1} 件の worktree 付き会話は、worktree ディレクトリをディスク上に残します。',
session_batch_archive_worktree_confirm: '{0} 件の会話をアーカイブしますか? {1} 件の worktree 付き会話は、worktree ディレクトリをディスク上に保持します。',
session_no_selection: '会話が選択されていません',
// settings panel
settings_heading_title: 'コントロールセンター',
@@ -4149,10 +4167,17 @@ const LOCALES = {
// Session management and settings keys (en fallback — pending translation)
session_archive: 'Archive conversation',
session_archive_desc: 'Hide this conversation until archived is shown',
session_archive_worktree_desc: 'Hide this conversation; keep its worktree on disk',
session_archive_failed: 'Archive failed: ',
session_archived: 'Session archived',
session_archived_worktree: 'Session archived. Worktree remains on disk.',
session_delete: 'Delete conversation',
session_delete_desc: 'Permanently remove this conversation',
session_delete_confirm: 'Delete this conversation?',
session_delete_worktree_desc: 'Delete only the WebUI conversation; keep the worktree on disk',
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
session_deleted: 'Conversation deleted',
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -4180,6 +4205,8 @@ const LOCALES = {
session_batch_move: 'Переместить в проект',
session_batch_delete_confirm: 'Удалить {0} бесед(ы)?',
session_batch_archive_confirm: 'Архивировать {0} бесед(ы)?',
session_batch_delete_worktree_confirm: 'Удалить {0} бесед(ы)? У {1} бесед с worktree каталоги worktree останутся на диске.',
session_batch_archive_worktree_confirm: 'Архивировать {0} бесед(ы)? У {1} бесед с worktree каталоги worktree останутся на диске.',
session_no_selection: 'Ничего не выбрано',
settings_dropdown_appearance: 'Appearance',
settings_dropdown_conversation: 'Conversation',
@@ -5170,10 +5197,17 @@ const LOCALES = {
// Session management and settings keys (en fallback — pending translation)
session_archive: 'Archive conversation',
session_archive_desc: 'Hide this conversation until archived is shown',
session_archive_worktree_desc: 'Ocultar esta conversación; conservar su worktree en disco',
session_archive_failed: 'Archive failed: ',
session_archived: 'Session archived',
session_archived_worktree: 'Sesión archivada. El worktree permanece en disco.',
session_delete: 'Delete conversation',
session_delete_desc: 'Permanently remove this conversation',
session_delete_confirm: '¿Eliminar esta conversación?',
session_delete_worktree_desc: 'Eliminar solo la conversación de WebUI; conservar el worktree en disco',
session_delete_worktree_confirm: (path) => `¿Eliminar esta conversación? El worktree en ${path} permanecerá en disco.`,
session_deleted: 'Conversación eliminada',
session_deleted_worktree: 'Conversación eliminada. El worktree permanece en disco.',
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -5201,6 +5235,8 @@ const LOCALES = {
session_batch_move: 'Mover al proyecto',
session_batch_delete_confirm: '¿Eliminar {0} conversaciones?',
session_batch_archive_confirm: '¿Archivar {0} conversaciones?',
session_batch_delete_worktree_confirm: '¿Eliminar {0} conversaciones? {1} conversaciones con worktree dejarán sus directorios de worktree en disco.',
session_batch_archive_worktree_confirm: '¿Archivar {0} conversaciones? {1} conversaciones con worktree conservarán sus directorios de worktree en disco.',
session_no_selection: 'Ninguna conversación seleccionada',
settings_dropdown_appearance: 'Appearance',
settings_dropdown_conversation: 'Conversation',
@@ -5931,10 +5967,17 @@ const LOCALES = {
// Session management and settings keys (en fallback — pending translation)
session_archive: 'Archive conversation',
session_archive_desc: 'Hide this conversation until archived is shown',
session_archive_worktree_desc: 'Diese Konversation ausblenden; den Worktree auf der Festplatte behalten',
session_archive_failed: 'Archive failed: ',
session_archived: 'Session archived',
session_archived_worktree: 'Sitzung archiviert. Der Worktree bleibt auf der Festplatte.',
session_delete: 'Delete conversation',
session_delete_desc: 'Permanently remove this conversation',
session_delete_confirm: 'Diese Konversation löschen?',
session_delete_worktree_desc: 'Nur die WebUI-Konversation löschen; den Worktree auf der Festplatte behalten',
session_delete_worktree_confirm: (path) => `Diese Konversation löschen? Der Worktree unter ${path} bleibt auf der Festplatte.`,
session_deleted: 'Konversation gelöscht',
session_deleted_worktree: 'Konversation gelöscht. Der Worktree bleibt auf der Festplatte.',
session_duplicate: 'Duplicate conversation',
session_duplicate_desc: 'Create a copy with the same workspace and model',
session_duplicate_failed: 'Duplicate failed: ',
@@ -5962,6 +6005,8 @@ const LOCALES = {
session_batch_move: 'Zum Projekt verschieben',
session_batch_delete_confirm: '{0} Konversationen löschen?',
session_batch_archive_confirm: '{0} Konversationen archivieren?',
session_batch_delete_worktree_confirm: '{0} Konversationen löschen? {1} worktree-gestützte Konversation(en) behalten ihre Worktree-Verzeichnisse auf der Festplatte.',
session_batch_archive_worktree_confirm: '{0} Konversationen archivieren? {1} worktree-gestützte Konversation(en) behalten ihre Worktree-Verzeichnisse auf der Festplatte.',
session_no_selection: 'Keine Konversationen ausgewählt',
settings_dropdown_appearance: 'Appearance',
settings_dropdown_conversation: 'Conversation',
@@ -7233,10 +7278,17 @@ const LOCALES = {
// Session management and settings keys (en fallback — pending translation)
session_archive: '归档会话',
session_archive_desc: '隐藏此会话,直到显示归档',
session_archive_worktree_desc: '隐藏此会话;保留磁盘上的 worktree',
session_archive_failed: '归档失败:',
session_archived: '会话已归档',
session_archived_worktree: '会话已归档。Worktree 仍保留在磁盘上。',
session_delete: '删除会话',
session_delete_desc: '永久删除此会话',
session_delete_confirm: '删除此会话?',
session_delete_worktree_desc: '仅删除 WebUI 会话;保留磁盘上的 worktree',
session_delete_worktree_confirm: (path) => `删除此会话?位于 ${path} 的 worktree 将保留在磁盘上。`,
session_deleted: '会话已删除',
session_deleted_worktree: '会话已删除。Worktree 仍保留在磁盘上。',
session_duplicate: '复制会话',
session_duplicate_desc: '用相同工作区和模型创建副本',
session_duplicate_failed: '复制失败:',
@@ -7264,6 +7316,8 @@ const LOCALES = {
session_batch_move: '移动到项目',
session_batch_delete_confirm: '删除 {0} 个会话?',
session_batch_archive_confirm: '归档 {0} 个会话?',
session_batch_delete_worktree_confirm: '删除 {0} 个会话?其中 {1} 个 worktree 会话会把 worktree 目录保留在磁盘上。',
session_batch_archive_worktree_confirm: '归档 {0} 个会话?其中 {1} 个 worktree 会话会把 worktree 目录保留在磁盘上。',
session_no_selection: '未选择任何会话',
settings_dropdown_appearance: '外观',
settings_dropdown_conversation: '对话',
@@ -7661,8 +7715,10 @@ const LOCALES = {
session_archive: '封存對話',
session_restore: '還原對話',
session_archive_desc: '隱藏此對話,直到開啟顯示封存',
session_archive_worktree_desc: '隱藏此對話;保留磁碟上的 worktree',
session_restore_desc: '將此對話移回主清單',
session_archived: '對話已封存',
session_archived_worktree: '對話已封存。Worktree 仍保留在磁碟上。',
session_restored: '對話已還原',
session_archive_failed: '封存失敗:',
session_duplicate: '複製對話',
@@ -7673,6 +7729,11 @@ const LOCALES = {
session_stop_response_desc: 'Cancel the running response for this conversation',
session_delete: '刪除對話',
session_delete_desc: '永久移除這個對話',
session_delete_confirm: '刪除此對話?',
session_delete_worktree_desc: '只刪除 WebUI 對話;保留磁碟上的 worktree',
session_delete_worktree_confirm: (path) => `刪除此對話?位於 ${path} 的 worktree 將保留在磁碟上。`,
session_deleted: '對話已刪除',
session_deleted_worktree: '對話已刪除。Worktree 仍保留在磁碟上。',
session_select_mode: '選取',
session_select_mode_desc: '選取會話以批次管理',
session_select_all: '全選',
@@ -7683,6 +7744,8 @@ const LOCALES = {
session_batch_move: '移至專案',
session_batch_delete_confirm: '刪除 {0} 個會話?',
session_batch_archive_confirm: '封存 {0} 個會話?',
session_batch_delete_worktree_confirm: '刪除 {0} 個會話?其中 {1} 個 worktree 會話會把 worktree 目錄保留在磁碟上。',
session_batch_archive_worktree_confirm: '封存 {0} 個會話?其中 {1} 個 worktree 會話會把 worktree 目錄保留在磁碟上。',
session_no_selection: '未選取任何會話',
// settings panel
settings_heading_title: '控制中心',
@@ -8854,8 +8917,10 @@ const LOCALES = {
session_archive: 'Arquivar conversa',
session_restore: 'Restaurar conversa',
session_archive_desc: 'Esconder conversa até mostrar arquivados',
session_archive_worktree_desc: 'Esconder esta conversa; manter o worktree no disco',
session_restore_desc: 'Trazer conversa de volta à lista principal',
session_archived: 'Sessão arquivada',
session_archived_worktree: 'Sessão arquivada. O worktree permanece no disco.',
session_restored: 'Sessão restaurada',
session_archive_failed: 'Falha ao arquivar: ',
session_duplicate: 'Duplicar conversa',
@@ -8866,6 +8931,13 @@ const LOCALES = {
session_stop_response_desc: 'Cancel the running response for this conversation',
session_delete: 'Excluir conversa',
session_delete_desc: 'Remover permanentemente esta conversa',
session_delete_confirm: 'Excluir esta conversa?',
session_delete_worktree_desc: 'Excluir apenas a conversa no WebUI; manter o worktree no disco',
session_delete_worktree_confirm: (path) => `Excluir esta conversa? O worktree em ${path} permanecerá no disco.`,
session_deleted: 'Conversa excluída',
session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.',
session_batch_delete_worktree_confirm: 'Excluir {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
session_batch_archive_worktree_confirm: 'Arquivar {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
// settings panel
settings_heading_title: 'Control Center',
settings_heading_subtitle: 'Preferências, ferramentas de conversa e controles do sistema.',
@@ -9840,8 +9912,10 @@ const LOCALES = {
session_archive: 'Archive conversation',
session_restore: 'Restore conversation',
session_archive_desc: 'Hide this conversation until archived is shown',
session_archive_worktree_desc: '이 대화를 숨기고 worktree는 디스크에 유지합니다',
session_restore_desc: 'Bring this conversation back into the main list',
session_archived: 'Session archived',
session_archived_worktree: '세션이 보관되었습니다. Worktree는 디스크에 남아 있습니다.',
session_restored: 'Session restored',
session_archive_failed: 'Archive failed: ',
session_duplicate: 'Duplicate conversation',
@@ -9852,6 +9926,11 @@ const LOCALES = {
session_stop_response_desc: 'Cancel the running response for this conversation',
session_delete: 'Delete conversation',
session_delete_desc: 'Permanently remove this conversation',
session_delete_confirm: '이 대화를 삭제하시겠습니까?',
session_delete_worktree_desc: 'WebUI 대화만 삭제하고 worktree는 디스크에 유지합니다',
session_delete_worktree_confirm: (path) => `이 대화를 삭제하시겠습니까? ${path}의 worktree는 디스크에 남아 있습니다.`,
session_deleted: '대화가 삭제되었습니다',
session_deleted_worktree: '대화가 삭제되었습니다. Worktree는 디스크에 남아 있습니다.',
session_select_mode: '선택',
session_select_mode_desc: '일괄 관리할 대화를 선택하세요',
session_select_all: '전체 선택',
@@ -9862,6 +9941,8 @@ const LOCALES = {
session_batch_move: '프로젝트로 이동',
session_batch_delete_confirm: '{0}개의 대화를 삭제하시겠습니까?',
session_batch_archive_confirm: '{0}개의 대화를 보관하시겠습니까?',
session_batch_delete_worktree_confirm: '{0}개의 대화를 삭제하시겠습니까? worktree가 있는 대화 {1}개는 worktree 디렉터리를 디스크에 남깁니다.',
session_batch_archive_worktree_confirm: '{0}개의 대화를 보관하시겠습니까? worktree가 있는 대화 {1}개는 worktree 디렉터리를 디스크에 유지합니다.',
session_no_selection: '선택된 대화가 없습니다',
// settings panel
settings_heading_title: '제어 센터',
+41 -9
View File
@@ -1279,6 +1279,27 @@ const SESSION_VIRTUAL_THRESHOLD_ROWS = 80;
let _sessionVirtualScrollList = null;
let _sessionVirtualScrollRaf = 0;
function _sessionSnapshotById(sid){
if(!sid)return null;
if(S.session&&S.session.session_id===sid) return S.session;
return (_allSessions||[]).find(s=>s&&s.session_id===sid)||null;
}
function _worktreeSessionCount(ids){
return (ids||[]).reduce((count,sid)=>{
const session=_sessionSnapshotById(sid);
return count+(session&&session.worktree_path?1:0);
},0);
}
function _sessionArchiveDescription(session){
return session&&session.worktree_path?t('session_archive_worktree_desc'):t('session_archive_desc');
}
function _sessionArchiveToast(session){
return session&&session.worktree_path?t('session_archived_worktree'):t('session_archived');
}
function _sessionDeleteDescription(session){
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
}
function _sessionIdFromLocation(){
if(typeof window==='undefined'||!window.location) return null;
const marker='/session/';
@@ -1376,10 +1397,15 @@ function _renderBatchActionBar(){
archiveBtn.textContent=t('session_batch_archive');
archiveBtn.onclick=async()=>{
const ids=[..._selectedSessions];
const ok=await showConfirmDialog({message:t('session_batch_archive_confirm',ids.length),confirmLabel:t('session_batch_archive'),danger:true});
const wtCount=_worktreeSessionCount(ids);
const ok=await showConfirmDialog({
message:wtCount?t('session_batch_archive_worktree_confirm',ids.length,wtCount):t('session_batch_archive_confirm',ids.length),
confirmLabel:t('session_batch_archive'),
danger:true
});
if(!ok)return;
try{await Promise.all(ids.map(sid=>api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})})));
showToast(t('session_archived'));exitSessionSelectMode();await renderSessionList();
showToast(wtCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
}catch(e){showToast('Archive failed: '+(e.message||e));}
};bar.appendChild(archiveBtn);
// Move
@@ -1391,7 +1417,12 @@ function _renderBatchActionBar(){
deleteBtn.textContent=t('session_batch_delete');
deleteBtn.onclick=async()=>{
const ids=[..._selectedSessions];
const ok=await showConfirmDialog({message:t('session_batch_delete_confirm',ids.length),confirmLabel:t('delete_title'),danger:true});
const wtCount=_worktreeSessionCount(ids);
const ok=await showConfirmDialog({
message:wtCount?t('session_batch_delete_worktree_confirm',ids.length,wtCount):t('session_batch_delete_confirm',ids.length),
confirmLabel:t('delete_title'),
danger:true
});
if(!ok)return;
try{
await Promise.all(ids.map(sid=>api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})})));
@@ -1402,7 +1433,7 @@ function _renderBatchActionBar(){
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
}
showToast(t('session_delete')+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
showToast((wtCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
}catch(e){showToast('Delete failed: '+(e.message||e));}
};bar.appendChild(deleteBtn);
}
@@ -1570,7 +1601,7 @@ function _openSessionActionMenu(session, anchorEl){
));
menu.appendChild(_buildSessionAction(
session.archived?t('session_restore'):t('session_archive'),
session.archived?t('session_restore_desc'):t('session_archive_desc'),
session.archived?t('session_restore_desc'):_sessionArchiveDescription(session),
session.archived?ICONS.unarchive:ICONS.archive,
async()=>{
closeSessionActionMenu();
@@ -1579,7 +1610,7 @@ function _openSessionActionMenu(session, anchorEl){
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await renderSessionList();
showToast(session.archived?t('session_archived'):t('session_restored'));
showToast(session.archived?_sessionArchiveToast(session):t('session_restored'));
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
));
@@ -1601,7 +1632,7 @@ function _openSessionActionMenu(session, anchorEl){
if(!isExternalSession){
menu.appendChild(_buildSessionAction(
t('session_delete'),
t('session_delete_desc'),
_sessionDeleteDescription(session),
ICONS.trash,
async()=>{
closeSessionActionMenu();
@@ -3013,8 +3044,9 @@ if(typeof window!=='undefined'){
}
async function deleteSession(sid){
const session=_sessionSnapshotById(sid);
const ok=await showConfirmDialog({
message:'Delete this conversation?',
message:session&&session.worktree_path?t('session_delete_worktree_confirm',session.worktree_path):t('session_delete_confirm'),
confirmLabel:t('delete_title'),
danger:true
});
@@ -3040,7 +3072,7 @@ async function deleteSession(sid){
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
}
}
showToast('Conversation deleted');
showToast(session&&session.worktree_path?t('session_deleted_worktree'):t('session_deleted'));
await renderSessionList();
}
@@ -0,0 +1,86 @@
from types import SimpleNamespace
import api.models as models
import api.routes as routes
from api.models import SESSIONS, Session
def _capture_post(monkeypatch, body):
captured = {}
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(routes, "read_body", lambda handler: body)
monkeypatch.setattr(
routes,
"j",
lambda handler, payload, status=200, extra_headers=None: captured.update(
payload=payload,
status=status,
)
or True,
)
return captured
def _isolate_session_store(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")
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", session_dir / "_index.json")
SESSIONS.clear()
return session_dir
def _worktree_session(tmp_path, session_id):
repo = tmp_path / "repo"
worktree = repo / ".worktrees" / f"hermes-{session_id}"
worktree.mkdir(parents=True)
s = Session(
session_id=session_id,
title="Worktree session",
workspace=str(worktree),
worktree_path=str(worktree),
worktree_branch=f"hermes/{session_id}",
worktree_repo_root=str(repo),
)
s.save()
return s, worktree
def test_delete_worktree_session_reports_retained_worktree_without_cleanup(tmp_path, monkeypatch):
session_dir = _isolate_session_store(tmp_path, monkeypatch)
session, worktree = _worktree_session(tmp_path, "wtdelete1")
captured = _capture_post(monkeypatch, {"session_id": session.session_id})
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {})
monkeypatch.setattr(routes, "_is_messaging_session_id", lambda sid: False)
monkeypatch.setattr(models, "delete_cli_session", lambda sid: None)
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/delete")) is True
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["worktree_retained"] is True
assert captured["payload"]["worktree_path"] == str(worktree.resolve())
assert captured["payload"]["worktree_branch"] == "hermes/wtdelete1"
assert not (session_dir / "wtdelete1.json").exists()
assert worktree.exists(), "session delete must not remove the git worktree directory"
def test_archive_worktree_session_reports_retained_worktree_without_cleanup(tmp_path, monkeypatch):
_isolate_session_store(tmp_path, monkeypatch)
session, worktree = _worktree_session(tmp_path, "wtarchive1")
captured = _capture_post(
monkeypatch,
{"session_id": session.session_id, "archived": True},
)
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/archive")) is True
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["session"]["archived"] is True
assert captured["payload"]["worktree_retained"] is True
assert captured["payload"]["worktree_path"] == str(worktree.resolve())
assert worktree.exists(), "session archive must not remove the git worktree directory"
assert Session.load("wtarchive1").archived is True
@@ -0,0 +1,52 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(path):
return (ROOT / path).read_text(encoding="utf-8")
def test_delete_confirmation_mentions_retained_worktree():
src = read("static/sessions.js")
i18n = read("static/i18n.js")
assert "function _sessionSnapshotById(sid)" in src
assert "session.worktree_path?t('session_delete_worktree_confirm',session.worktree_path)" in src
assert "session_delete_worktree_confirm" in i18n
assert "will remain on disk" in i18n
assert "session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`" in i18n
assert "session_delete_worktree_desc: 'Delete only the WebUI conversation; keep the worktree on disk'" in i18n
assert "session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.'" in i18n
def test_batch_archive_delete_confirmations_count_worktree_sessions():
src = read("static/sessions.js")
i18n = read("static/i18n.js")
assert "function _worktreeSessionCount(ids)" in src
assert "session_batch_delete_worktree_confirm" in src
assert "session_batch_archive_worktree_confirm" in src
assert "session_batch_delete_worktree_confirm" in i18n
assert "session_batch_archive_worktree_confirm" in i18n
def test_archive_and_delete_action_descriptions_are_worktree_specific():
src = read("static/sessions.js")
i18n = read("static/i18n.js")
assert "function _sessionArchiveDescription(session)" in src
assert "function _sessionDeleteDescription(session)" in src
assert "session&&session.worktree_path?t('session_archive_worktree_desc')" in src
assert "session&&session.worktree_path?t('session_delete_worktree_desc')" in src
assert "session_archive_worktree_desc" in i18n
assert "session_delete_worktree_desc" in i18n
assert "session_archive_worktree_desc: 'Hide this conversation; keep its worktree on disk'" in i18n
assert "session_archived_worktree: 'Session archived. Worktree remains on disk.'" in i18n
def test_worktree_archive_delete_api_responses_are_explicit():
src = read("api/routes.py")
assert "def _worktree_retained_payload(session)" in src
assert "def _worktree_retained_payload_for_session_id(sid: str)" in src
assert '"worktree_retained": True' in src
assert '{"ok": True, **worktree_retained}' in src
assert '{"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}' in src