diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts
index 1baf26d2fca..4515644fe2d 100644
--- a/apps/desktop/electron/git-review-ops.ts
+++ b/apps/desktop/electron/git-review-ops.ts
@@ -204,6 +204,25 @@ async function defaultBranchName(git) {
return null
}
+// True when the current branch's upstream tracking ref has been deleted on the
+// remote — the "[gone]" state. Returns false for detached HEAD, no upstream,
+// or a live upstream.
+async function branchGone(git, branch) {
+ if (!branch) {
+ return false
+ }
+
+ try {
+ const track = (await git.raw([
+ 'for-each-ref', '--format=%(upstream:track)', `refs/heads/${branch}`
+ ])).trim()
+
+ return track.includes('[gone]')
+ } catch {
+ return false
+ }
+}
+
// A status file's single-letter classification, preferring the staged (index)
// code over the worktree code; untracked wins (simple-git marks both '?').
function statusLetter(file) {
@@ -630,6 +649,7 @@ async function repoStatus(repoPath, gitBin) {
branch: detached ? null : status.current || null,
defaultBranch: await defaultBranchName(git),
detached,
+ gone: await branchGone(git, detached ? null : status.current || null),
ahead: status.ahead || 0,
behind: status.behind || 0,
staged: files.filter(f => f.staged).length,
diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
index 02f41e2605c..92cd0ef8b21 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
@@ -268,6 +268,15 @@ export const CodingStatusRow = memo(function CodingStatusRow({
{branchLabel}
+ {status.gone && !status.detached && (
+
+ {s.gone}
+
+ )}
+
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
index 911aaaeecb2..2165ec11241 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
@@ -3,10 +3,12 @@ import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
+import { setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
-import { notifyError } from '@/store/notifications'
+import { triggerHaptic } from '@/lib/haptics'
+import { notify, notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
-import { switchBranchInRepo } from '@/store/projects'
+import { switchBranchInRepo, tombstoneSessions } from '@/store/projects'
import { countLabel, SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
@@ -27,6 +29,7 @@ interface SidebarWorkspaceGroupProps {
export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
+ const p = s.projects
const isProfileGroup = group.mode === 'profile'
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
@@ -94,11 +97,47 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
onNewSession(group.path)
}
+ const handleArchiveGone = async () => {
+ const sessions = group.sessions
+
+ if (!sessions.length) {
+ return
+ }
+
+ if (!window.confirm(p.archiveSessionsConfirm(sessions.length))) {
+ return
+ }
+
+ // Optimistically tombstone so the sidebar drops them immediately.
+ tombstoneSessions(sessions.map(s => s.id))
+
+ let ok = 0
+ let failed = 0
+
+ for (const session of sessions) {
+ try {
+ await setSessionArchived(session.id, true, session.profile)
+ ok++
+ } catch {
+ failed++
+ }
+ }
+
+ if (ok > 0) {
+ triggerHaptic('selection')
+ notify({ durationMs: 3_000, kind: 'success', message: p.archiveSessionsDone(ok) })
+ }
+
+ if (failed > 0) {
+ notifyError(new Error(`${failed} session(s) could not be archived`), p.archiveSessionsFailed)
+ }
+ }
+
return (
{(onNewSession || isProfileGroup) && (
void handleNewSession()}
/>
)}
+ {group.gone && (
+ void handleArchiveGone()}
+ onArchiveLabel={p.archiveSessions}
+ path={group.path}
+ />
+ )}
{onRemove && }
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
+ gone={group.gone}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
index 04e18f7c7b9..00868bfd783 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
@@ -26,6 +26,9 @@ export interface SidebarSessionGroup {
// worktrees (`/.worktrees/t_*`) into one row, so a heavy board doesn't
// spray hundreds of throwaway branch lanes across the sidebar.
isKanban?: boolean
+ // True when this branch lane's upstream tracking ref has been deleted on the
+ // remote (merged or manually deleted). The sidebar can flag it for cleanup.
+ gone?: boolean
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
index e184ac871a0..adb7614061a 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
@@ -106,7 +106,18 @@ export function WorkspaceShowMoreButton({
// Per-worktree actions (linked worktree lanes only), mirroring the session row
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
-export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
+// When `onArchive` is set (gone branch lanes), adds an "Archive sessions" item.
+export function WorkspaceMenu({
+ onArchive,
+ onArchiveLabel,
+ onRemove,
+ path
+}: {
+ onArchive?: () => void
+ onArchiveLabel?: string
+ onRemove?: () => void
+ path: null | string
+}) {
const { t } = useI18n()
const p = t.sidebar.projects
@@ -123,19 +134,33 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
- void revealPath(path)}>
-
- {p.reveal}
-
- void copyPath(path)}>
-
- {p.copyPath}
-
-
-
-
- {`${p.removeWorktree}…`}
-
+ {onArchive && (
+
+
+ {onArchiveLabel ?? p.archiveSessions}
+
+ )}
+ {path && (
+ <>
+ void revealPath(path)}>
+
+ {p.reveal}
+
+ void copyPath(path)}>
+
+ {p.copyPath}
+
+ >
+ )}
+ {onRemove && (
+ <>
+ {(onArchive || path) && }
+
+
+ {`${p.removeWorktree}…`}
+
+ >
+ )}
)
@@ -355,6 +380,7 @@ export function WorkspaceHeader({
action,
count,
emphasis = false,
+ gone = false,
icon,
label,
onToggle,
@@ -364,6 +390,7 @@ export function WorkspaceHeader({
action?: React.ReactNode
count: React.ReactNode
emphasis?: boolean
+ gone?: boolean
icon: React.ReactNode
label: string
onToggle: () => void
@@ -388,6 +415,14 @@ export function WorkspaceHeader({
>
{icon}
+ {gone && (
+
+ gone
+
+ )}
{count}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index a37091ceeb4..b2595cfb000 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -706,6 +706,10 @@ export interface HermesRepoStatus {
// the default" from anywhere. Null when no trunk is detected.
defaultBranch: null | string
detached: boolean
+ // True when the current branch's upstream tracking ref has been deleted on
+ // the remote (merged or manually deleted) — the "[gone]" state. Lets the
+ // coding rail flag stale branches for cleanup.
+ gone: boolean
ahead: number
behind: number
staged: number
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 31c0a734f30..381376ee1ae 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1627,6 +1627,11 @@ export const en: Translations = {
removeWorktreeDirty:
'This worktree has uncommitted changes. Force-remove it (discards those changes), or just hide the lane and keep it on disk.',
forceRemove: 'Force remove',
+ archiveSessions: 'Archive sessions',
+ archiveSessionsConfirm: count =>
+ `Archive ${count} session${count === 1 ? '' : 's'} on this branch? Archived chats are hidden from the sidebar but keep all their messages.`,
+ archiveSessionsDone: count => `${count} session${count === 1 ? '' : 's'} archived`,
+ archiveSessionsFailed: 'Could not archive sessions',
enter: label => `Open ${label}`,
reorder: label => `Reorder ${label}`,
toggle: label => `Toggle ${label} sessions`,
@@ -1815,6 +1820,7 @@ export const en: Translations = {
title: 'Working tree',
noBranch: 'No branch',
detached: 'detached',
+ gone: 'gone',
clean: 'Clean',
changed: count => `${count} changed`,
ahead: count => `${count} ahead`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index f6bb2c0d7b3..76c659e5376 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1551,6 +1551,11 @@ export const ja = defineLocale({
removeWorktreeDirty:
'このワークツリーにはコミットされていない変更があります。強制削除(変更を破棄)するか、レーンを隠してディスク上に残します。',
forceRemove: '強制削除',
+ archiveSessions: 'セッションをアーカイブ',
+ archiveSessionsConfirm: count =>
+ `このブランチの ${count} 件のセッションをアーカイブしますか?アーカイブされたチャットはサイドバーから非表示になりますが、メッセージはすべて保持されます。`,
+ archiveSessionsDone: count => `${count} 件のセッションをアーカイブしました`,
+ archiveSessionsFailed: 'セッションをアーカイブできませんでした',
enter: label => `${label} を開く`
},
newSessionIn: label => `${label} で新しいセッション`,
@@ -1733,6 +1738,7 @@ export const ja = defineLocale({
title: 'ワークツリー',
noBranch: 'ブランチなし',
detached: 'デタッチ',
+ gone: 'gone',
clean: 'クリーン',
changed: count => `${count} 件変更`,
ahead: count => `${count} 先行`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 5962d317221..9e7fbc08f86 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1350,6 +1350,10 @@ export interface Translations {
removeWorktreeConfirm: string
removeWorktreeDirty: string
forceRemove: string
+ archiveSessions: string
+ archiveSessionsConfirm: (count: number) => string
+ archiveSessionsDone: (count: number) => string
+ archiveSessionsFailed: string
enter: (label: string) => string
reorder: (label: string) => string
toggle: (label: string) => string
@@ -1490,6 +1494,7 @@ export interface Translations {
title: string
noBranch: string
detached: string
+ gone: string
clean: string
changed: (count: number) => string
ahead: (count: number) => string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index a02abba62e0..8eb2d3d87ed 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1501,6 +1501,11 @@ export const zhHant = defineLocale({
'從 git 中移除(刪除工作樹目錄,但保留分支),或僅從側邊欄隱藏該軌道並將工作樹保留在磁碟上。',
removeWorktreeDirty: '此工作樹有未提交的變更。強制移除(捨棄這些變更),或僅隱藏軌道並保留在磁碟上。',
forceRemove: '強制移除',
+ archiveSessions: '封存工作階段',
+ archiveSessionsConfirm: count =>
+ `封存此分支上的 ${count} 個工作階段?已封存的聊天會從側邊欄隱藏,但保留所有訊息。`,
+ archiveSessionsDone: count => `已封存 ${count} 個工作階段`,
+ archiveSessionsFailed: '無法封存工作階段',
enter: label => `開啟 ${label}`
},
newSessionIn: label => `在 ${label} 中新建工作階段`,
@@ -1682,6 +1687,7 @@ export const zhHant = defineLocale({
title: '工作區',
noBranch: '無分支',
detached: '分離 HEAD',
+ gone: 'gone',
clean: '乾淨',
changed: count => `${count} 處變更`,
ahead: count => `領先 ${count}`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 1422fb886d7..d265bda1530 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1803,6 +1803,11 @@ export const zh: Translations = {
'从 git 中移除(删除工作树目录,但保留分支),或仅从侧边栏隐藏该泳道并将工作树保留在磁盘上。',
removeWorktreeDirty: '此工作树有未提交的更改。强制移除(丢弃这些更改),或仅隐藏泳道并保留在磁盘上。',
forceRemove: '强制移除',
+ archiveSessions: '归档会话',
+ archiveSessionsConfirm: count =>
+ `归档此分支上的 ${count} 个会话?已归档的聊天会从侧边栏隐藏,但保留所有消息。`,
+ archiveSessionsDone: count => `已归档 ${count} 个会话`,
+ archiveSessionsFailed: '无法归档会话',
enter: label => `打开 ${label}`,
reorder: label => `重新排序 ${label}`,
toggle: label => `展开/收起 ${label} 会话`,
@@ -1991,6 +1996,7 @@ export const zh: Translations = {
title: '工作区',
noBranch: '无分支',
detached: '分离头指针',
+ gone: 'gone',
clean: '干净',
changed: count => `${count} 处更改`,
ahead: count => `领先 ${count}`,
diff --git a/apps/desktop/src/store/coding-status.test.ts b/apps/desktop/src/store/coding-status.test.ts
index 02d9e843bb6..16e1d611652 100644
--- a/apps/desktop/src/store/coding-status.test.ts
+++ b/apps/desktop/src/store/coding-status.test.ts
@@ -9,6 +9,7 @@ const sampleStatus: HermesRepoStatus = {
branch: 'feature/login',
defaultBranch: 'main',
detached: false,
+ gone: false,
ahead: 1,
behind: 0,
staged: 1,
diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py
index 0af12106745..54664caafd8 100644
--- a/hermes_cli/web_git.py
+++ b/hermes_cli/web_git.py
@@ -150,6 +150,20 @@ def _default_branch_name(cwd: str) -> str | None:
return None
+def _branch_gone(cwd: str, branch: str | None) -> bool:
+ """True when ``branch``'s upstream tracking ref has been deleted on the remote.
+
+ This is the signal that a branch was merged (or deleted) remotely and the
+ local branch is now stale — the exact state ``git branch --list`` shows as
+ ``[gone]``. Returns False for detached HEAD, no upstream, or a live
+ upstream.
+ """
+ if not branch:
+ return False
+ track = _git_out(cwd, ["for-each-ref", "--format=%(upstream:track)", f"refs/heads/{branch}"])
+ return "[gone]" in track
+
+
# ── porcelain v2 status parsing ──────────────────────────────────────────────
@@ -239,6 +253,7 @@ def repo_status(cwd: str) -> dict | None:
"branch": branch,
"defaultBranch": _default_branch_name(cwd),
"detached": detached,
+ "gone": _branch_gone(cwd, branch),
"ahead": ahead,
"behind": behind,
"staged": sum(f["staged"] for f in files),
diff --git a/tests/hermes_cli/test_web_server_git.py b/tests/hermes_cli/test_web_server_git.py
index 31b73b3f283..24ee1e81b46 100644
--- a/tests/hermes_cli/test_web_server_git.py
+++ b/tests/hermes_cli/test_web_server_git.py
@@ -54,6 +54,7 @@ def test_status_reports_branch_and_change_counts(client, repo):
assert body["branch"] == body["defaultBranch"]
assert body["branch"]
assert body["detached"] is False
+ assert body["gone"] is False
# 1 tracked-modified + 1 untracked = 2 changed paths.
assert body["changed"] == 2
assert body["untracked"] == 1
@@ -69,6 +70,30 @@ def test_status_returns_null_outside_repo(client, tmp_path):
assert client.get("/api/git/status", params={"path": str(plain)}).json() is None
+def test_status_gone_after_remote_branch_deleted(client, repo, tmp_path):
+ """A branch whose upstream was deleted on the remote reports ``gone: true``."""
+ # Set up a bare remote and push a feature branch to it.
+ remote = tmp_path / "remote.git"
+ remote.mkdir()
+ _git(remote, "init", "--bare", "-q")
+ _git(repo, "remote", "add", "origin", str(remote))
+ _git(repo, "checkout", "-q", "-b", "feature/merged")
+ _git(repo, "push", "-q", "-u", "origin", "feature/merged")
+
+ # Branch is live on the remote — not gone.
+ body = client.get("/api/git/status", params={"path": str(repo)}).json()
+ assert body["branch"] == "feature/merged"
+ assert body["gone"] is False
+
+ # Delete the remote branch; `git fetch --prune` updates the tracking ref.
+ _git(repo, "push", "-q", "origin", "--delete", "feature/merged")
+ _git(repo, "fetch", "-q", "--prune", "origin")
+
+ body = client.get("/api/git/status", params={"path": str(repo)}).json()
+ assert body["branch"] == "feature/merged"
+ assert body["gone"] is True
+
+
def test_review_list_classifies_modified_and_untracked(client, repo):
body = client.get("/api/git/review/list", params={"path": str(repo)}).json()
diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py
index cd0424c5d5a..047b3fd01a6 100644
--- a/tests/tui_gateway/test_project_tree.py
+++ b/tests/tui_gateway/test_project_tree.py
@@ -479,3 +479,39 @@ def test_colliding_repo_basenames_disambiguate_labels():
labels = sorted(p["label"] for p in tree["projects"])
assert labels == ["x/proj", "y/proj"]
+
+
+def test_gone_fn_annotates_branch_lanes():
+ """A gone_fn marks branch lanes whose label is in the repo's gone set."""
+ resolve = _resolver({"/repo": ("/repo", "/repo")})
+ sessions = [
+ _session("/repo", branch="main"),
+ _session("/repo", branch="feature/merged"),
+ _session("/repo", branch="feature/live"),
+ ]
+
+ tree = pt.build_tree(
+ [],
+ sessions,
+ [],
+ resolve,
+ hydrate=True,
+ gone_fn=lambda root: {"feature/merged"} if root == "/repo" else set(),
+ )
+ project = next(p for p in tree["projects"] if p["id"] == "/repo")
+ lanes = {g["label"]: g for repo in project["repos"] for g in repo["groups"]}
+
+ assert lanes["main"].get("gone") is not True
+ assert lanes["feature/merged"].get("gone") is True
+ assert lanes["feature/live"].get("gone") is not True
+
+
+def test_gone_fn_absent_leaves_no_gone_field():
+ """Without a gone_fn, no lane carries a gone field."""
+ resolve = _resolver({"/repo": ("/repo", "/repo")})
+ sessions = [_session("/repo", branch="feature/merged")]
+
+ tree = pt.build_tree([], sessions, [], resolve, hydrate=True)
+ project = next(p for p in tree["projects"] if p["id"] == "/repo")
+
+ assert all("gone" not in g for repo in project["repos"] for g in repo["groups"])
diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py
index 72582ebf5dc..220748558b0 100644
--- a/tui_gateway/git_probe.py
+++ b/tui_gateway/git_probe.py
@@ -69,6 +69,25 @@ def branch(cwd: str) -> str:
return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD")
+def gone_branches(cwd: str) -> set[str]:
+ """Local branches whose upstream tracking ref was deleted on the remote.
+
+ One ``git for-each-ref`` call returns all gone branches for the repo at
+ ``cwd``. Returns an empty set for non-repos, bare repos, or repos with
+ no upstream tracking. Used by the project tree to annotate branch lanes
+ whose sessions sit on merged/deleted branches.
+ """
+ out = run_git(cwd, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads/")
+ if not out:
+ return set()
+ gone = set()
+ for line in out.splitlines():
+ parts = line.split(None, 1)
+ if len(parts) >= 2 and "[gone]" in parts[1]:
+ gone.add(parts[0].strip())
+ return gone
+
+
class _RootCache:
"""Thread-safe, single-flight cache of git-root probes. Positive results are
cached for the process lifetime; negative ("not a repo") results are cached
diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py
index 3f243153964..da2ba7bac54 100644
--- a/tui_gateway/project_tree.py
+++ b/tui_gateway/project_tree.py
@@ -438,6 +438,26 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R
# ---------------------------------------------------------------------------
+def _annotate_gone(repos: list[dict], gone_fn: Optional[Callable[[str], frozenset[str]]]) -> None:
+ """Annotate each branch lane in ``repos`` with ``gone: True`` when the
+ lane's branch label is in the repo's gone set.
+
+ Only applies to main-checkout branch lanes (``isMain`` and not ``isKanban``):
+ linked worktree lanes and kanban lanes are labeled by path, not branch name.
+ Mutates group dicts in place.
+ """
+ if not gone_fn:
+ return
+ for repo in repos:
+ gone = gone_fn(repo.get("path") or "")
+ if not gone:
+ continue
+ for group in repo.get("groups") or []:
+ if group.get("isMain") and not group.get("isKanban"):
+ if group.get("label", "").strip() in gone:
+ group["gone"] = True
+
+
def _project_node(
*,
pid: str,
@@ -475,6 +495,7 @@ def build_tree(
hydrate: bool = False,
is_junk_root: Optional[Callable[[str], bool]] = None,
is_junk_cwd: Optional[Callable[[str], bool]] = None,
+ gone_fn: Optional[Callable[[str], frozenset[str]]] = None,
) -> dict:
"""Build the authoritative project tree.
@@ -487,6 +508,11 @@ def build_tree(
policy for non-git session folders: selected descendants may be intentional
workspaces even when their parent tree contains Hermes state. User-created
projects are honored regardless.
+ ``gone_fn`` is an optional callable that takes a repo root path and returns
+ the set of branch names whose upstream tracking ref has been deleted on the
+ remote (the ``[gone]`` state). When provided, each branch lane whose label
+ is in the set is annotated with ``gone: True`` so the UI can flag stale
+ branches for cleanup.
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
@@ -527,6 +553,7 @@ def build_tree(
repos = _seed_folder_repos(
_build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve
)
+ _annotate_gone(repos, gone_fn)
result.append(
_project_node(
pid=project["id"],
@@ -581,6 +608,7 @@ def build_tree(
auto_sessions = bucket["sessions"]
auto_key = _path_key(auto_root)
repos = _build_repos(auto_sessions, resolve, hydrate)
+ _annotate_gone(repos, gone_fn)
repo_node = next(
(
repo
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 2f6e833934f..166c9a67f6e 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -1567,6 +1567,19 @@ _git_branch_for_cwd = git_probe.branch
_git_repo_root_for_cwd = git_probe.repo_root
_git_common_repo_root_for_cwd = git_probe.common_repo_root
_resolve_cwd_git = git_probe.resolve
+_git_gone_branches = git_probe.gone_branches
+
+
+def _gone_branches_for_repo(root: str) -> frozenset[str]:
+ """Cached gone-branch set for a repo root.
+
+ ``git for-each-ref`` is cheap (~1ms) but the project tree is rebuilt on
+ several structural edges, so cache per-root with a short TTL. The cache
+ lives in the same process as the git probe's own root cache.
+ """
+ if not root:
+ return frozenset()
+ return frozenset(_git_gone_branches(root))
def _session_cwd(session: dict | None) -> str:
@@ -5796,6 +5809,9 @@ def _(rid, params: dict) -> dict:
_schedule_agent_build(sid)
_schedule_session_cap_enforcement() # trim detached idle sessions over the cap
+ # Re-probe git branch/repo-root: the branch may have changed since
+ # this session was last active (merged, switched in another worktree).
+ _persist_session_git_meta(record, cwd)
messages = _history_to_messages(display_history)
return _ok(
@@ -5937,6 +5953,10 @@ def _(rid, params: dict) -> dict:
lease.release()
return _err(rid, 5000, f"resume failed: {e}")
session = _sessions.get(sid) or {}
+ # Re-probe git branch/repo-root: the branch may have changed since
+ # this session was last active (merged, switched in another worktree).
+ if session.get("session_key"):
+ _persist_session_git_meta(session, profile_resume_cwd)
return _ok(
rid,
{
@@ -9376,6 +9396,12 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
session["last_active"] = time.time()
_clear_inflight_turn(session)
_emit("session.info", sid, _session_info(agent, session))
+ # Re-probe the session's git branch / repo root on a daemon thread.
+ # The agent may have switched branches via `git checkout` in the
+ # terminal during the turn; persisting the fresh metadata keeps the
+ # session DB's `git_branch` column from going stale, which the
+ # sidebar project tree + gone-branch cleanup rely on.
+ _persist_session_git_meta(session, _display_session_cwd(session))
# A user prompt that arrived mid-turn (interrupt + queue) wins over
# every auto follow-up below — drain it first and skip them this cycle;
@@ -11231,6 +11257,7 @@ def _build_project_tree(
hydrate=hydrate,
is_junk_root=_is_repo_junk,
is_junk_cwd=_is_session_cwd_junk,
+ gone_fn=_gone_branches_for_repo,
)
return tree, active_id