diff --git a/api/routes.py b/api/routes.py index 5d88ea2d..49b0d81c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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": diff --git a/docs/pr-media/2064/bulk-archive-confirm-desktop.png b/docs/pr-media/2064/bulk-archive-confirm-desktop.png new file mode 100644 index 00000000..981028e7 Binary files /dev/null and b/docs/pr-media/2064/bulk-archive-confirm-desktop.png differ diff --git a/docs/pr-media/2064/bulk-delete-confirm-desktop.png b/docs/pr-media/2064/bulk-delete-confirm-desktop.png new file mode 100644 index 00000000..1558606e Binary files /dev/null and b/docs/pr-media/2064/bulk-delete-confirm-desktop.png differ diff --git a/docs/pr-media/2064/single-delete-confirm-desktop.png b/docs/pr-media/2064/single-delete-confirm-desktop.png new file mode 100644 index 00000000..5cc7af5b Binary files /dev/null and b/docs/pr-media/2064/single-delete-confirm-desktop.png differ diff --git a/docs/pr-media/2064/single-delete-confirm-mobile-390.png b/docs/pr-media/2064/single-delete-confirm-mobile-390.png new file mode 100644 index 00000000..2974c9f6 Binary files /dev/null and b/docs/pr-media/2064/single-delete-confirm-mobile-390.png differ diff --git a/docs/pr-media/2064/single-session-action-menu-desktop.png b/docs/pr-media/2064/single-session-action-menu-desktop.png new file mode 100644 index 00000000..0011f29c Binary files /dev/null and b/docs/pr-media/2064/single-session-action-menu-desktop.png differ diff --git a/static/i18n.js b/static/i18n.js index 24d305df..29ee9bf0 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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: '제어 센터', diff --git a/static/sessions.js b/static/sessions.js index 04794a6d..35724911 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(); } diff --git a/tests/test_issue2057_worktree_lifecycle.py b/tests/test_issue2057_worktree_lifecycle.py new file mode 100644 index 00000000..c908f48f --- /dev/null +++ b/tests/test_issue2057_worktree_lifecycle.py @@ -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 diff --git a/tests/test_issue2057_worktree_ui_static.py b/tests/test_issue2057_worktree_ui_static.py new file mode 100644 index 00000000..9e1653d4 --- /dev/null +++ b/tests/test_issue2057_worktree_ui_static.py @@ -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