From d5f19e588eb28c516944abc2a06666f33d053673 Mon Sep 17 00:00:00 2001 From: Pablo Siber Date: Sun, 5 Jul 2026 17:59:17 +0000 Subject: [PATCH 1/2] fix: restore TitleLocked when re-applying a pending rename after reload race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual session rename could silently revert to the cwd-folder default on the next turn. When a rename's save was skipped (isReloading=true), the title was queued in pendingTitleChanges and re-applied after the storage-watcher reload swap — but the queue stored only the title string, dropping its TitleLocked intent. The reapplied title came back unlocked, so the next #572 Claude-name sync (ReconcileTitleFromClaude) overwrote it with Claude Code 2.1.19x's auto-derived folder name. Carry the lock state alongside the title (new pendingTitle struct). All three writers capture inst.TitleLocked at enqueue time — true for a user rename via SetField, false for a sync-sourced title — and the reapply restores it, so a user rename stays locked while a synced title keeps tracking Claude. Pinned by TestHomeRenamePendingChangeRestoresTitleLock (both directions). --- CHANGELOG.md | 4 ++ internal/ui/home.go | 54 ++++++++++++++----- internal/ui/home_test.go | 83 +++++++++++++++++++++++++++-- internal/ui/tui_eval_seam_b_test.go | 2 +- 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3896e4885..8ca56b550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A manual session rename no longer reverts to the folder default after a reload race.** When a rename's save was skipped (`isReloading=true`), the title was queued in `pendingTitleChanges` and re-applied after the storage-watcher reload — but only the title *string* was queued, not its `TitleLocked` intent. The reapplied title therefore came back **unlocked**, so the very next `#572` Claude-name sync overwrote it with Claude Code 2.1.19x's auto-derived cwd-folder name (e.g. `myproject` → `myproject-3a`). The queue now carries the lock state alongside the title: a user rename is restored **locked** (survives the sync), while a sync-sourced title stays unlocked (keeps tracking Claude). Pinned by `TestHomeRenamePendingChangeRestoresTitleLock`. (related to [#697](https://github.com/asheshgoplani/agent-deck/issues/697)) + ## [1.10.9] - 2026-07-02 ### Fixed diff --git a/internal/ui/home.go b/internal/ui/home.go index 021411914..6da362c17 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -198,6 +198,15 @@ const ( // Below 12: minimal mode ) +// pendingTitle is a title change queued to survive a storage-watcher reload +// swap (see Home.pendingTitleChanges). It carries the intended lock state +// alongside the title so the reapply can restore both: a user rename is locked, +// a Claude-name sync stays unlocked. +type pendingTitle struct { + title string + locked bool +} + // Home is the main application model type Home struct { // Dimensions @@ -512,8 +521,12 @@ type Home struct { // Pending title changes: survives reload races. // When a rename save is skipped (isReloading=true), the title change is - // stored here and re-applied after the reload completes. - pendingTitleChanges map[string]string + // stored here and re-applied after the reload completes. The lock state is + // carried alongside the title: a user rename is locked (so the #572 + // Claude-name sync can't revert it to the cwd-folder default), while a + // sync-sourced title stays unlocked so it keeps syncing. Storing only the + // string lost that intent and left reapplied user renames unlocked (#697). + pendingTitleChanges map[string]pendingTitle // UI state persistence across restarts pendingCursorRestore *uiState // Consumed on first loadSessionsMsg to restore cursor @@ -1144,7 +1157,7 @@ func NewHomeWithProfileAndMode(profile string) *Home { notesEditor: newNotesEditor(), boundKeys: make(map[string]string), undoStack: make([]deletedSessionEntry, 0, 10), - pendingTitleChanges: make(map[string]string), + pendingTitleChanges: make(map[string]pendingTitle), debugMode: logging.IsDebugEnabled(), lastClickIndex: -1, } @@ -4693,21 +4706,29 @@ func (h *Home) updateInner(msg tea.Msg) (tea.Model, tea.Cmd) { // and the reload replaced instances with stale disk data. if len(h.pendingTitleChanges) > 0 { applied := false - for id, title := range h.pendingTitleChanges { + for id, pt := range h.pendingTitleChanges { if inst := h.getInstanceByID(id); inst != nil { - if inst.Title != title { - inst.Title = title + if inst.Title != pt.title { + inst.Title = pt.title inst.SyncTmuxDisplayName() applied = true uiLog.Info("pending_rename_reapplied", slog.String("session_id", id), - slog.String("title", title)) + slog.String("title", pt.title)) + } + // Restore the lock state lost in the reload swap. Without + // this a reapplied user rename stays unlocked, so the next + // #572 Claude-name sync reverts it to the cwd-folder + // default — the "my rename keeps disappearing" bug (#697). + if inst.TitleLocked != pt.locked { + inst.TitleLocked = pt.locked + applied = true } inst.SetAutoName(false) // pending title is a genuine rename; keep the user-chosen name } } // Clear pending changes and persist if any were re-applied - h.pendingTitleChanges = make(map[string]string) + h.pendingTitleChanges = make(map[string]pendingTitle) if applied { h.forceSaveInstances() } @@ -9442,7 +9463,7 @@ func (h *Home) handleEditSessionDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Mirror the rename-path #697 race fix: queue title so a watcher // reload can re-apply it after the load swap. if titleChanged { - h.pendingTitleChanges[sessionID] = inst.Title + h.pendingTitleChanges[sessionID] = pendingTitle{title: inst.Title, locked: inst.TitleLocked} h.invalidatePreviewCache(sessionID) } h.rebuildFlatItems() @@ -9687,16 +9708,18 @@ func (h *Home) handleGroupDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // SetField so the rename also sets TitleLocked — a direct // Title assignment would be reverted by the #572 // Claude-name sync on the next hook event. + locked := true // SetField(FieldTitle) locks; default for the nil-inst path if inst := h.getInstanceByID(sessionID); inst != nil { if _, _, err := session.SetField(inst, session.FieldTitle, newName, nil); err != nil { h.setError(err) } + locked = inst.TitleLocked } // Store pending title change so it survives reload races. // If saveInstances() is skipped (isReloading=true), the reload - // replaces h.instances from disk, losing the in-memory rename. - // loadSessionsMsg re-applies pending changes after reload. - h.pendingTitleChanges[sessionID] = newName + // replaces h.instances from disk, losing the in-memory rename + // AND its lock. loadSessionsMsg re-applies both after reload. + h.pendingTitleChanges[sessionID] = pendingTitle{title: newName, locked: locked} // Invalidate preview cache since title changed h.invalidatePreviewCache(sessionID) h.rebuildFlatItems() @@ -9927,7 +9950,7 @@ func (h *Home) saveInstancesWithForce(force bool) { } // Clear pending title changes on successful save (rename was persisted) if len(h.pendingTitleChanges) > 0 { - h.pendingTitleChanges = make(map[string]string) + h.pendingTitleChanges = make(map[string]pendingTitle) } } } @@ -11705,7 +11728,10 @@ func (h *Home) attachSession(inst *session.Instance) tea.Cmd { sessionID = inst.ClaudeSessionID } if newName, changed := inst.ReconcileTitleFromClaude(sessionID); changed { - h.pendingTitleChanges[inst.ID] = newName + // A sync-sourced title stays unlocked (TitleLocked is false here, + // since ReconcileTitleFromClaude only runs on unlocked sessions) so + // it keeps tracking Claude's session name across reloads. + h.pendingTitleChanges[inst.ID] = pendingTitle{title: newName, locked: inst.TitleLocked} h.invalidatePreviewCache(inst.ID) h.rebuildFlatItems() h.saveInstances() diff --git a/internal/ui/home_test.go b/internal/ui/home_test.go index 673dc2a88..0ddd0795f 100644 --- a/internal/ui/home_test.go +++ b/internal/ui/home_test.go @@ -618,7 +618,7 @@ func TestHomeRenamePendingChangesSurviveReload(t *testing.T) { home.rebuildFlatItems() // Simulate a rename that stores a pending title change - home.pendingTitleChanges[inst.ID] = "renamed-title" + home.pendingTitleChanges[inst.ID] = pendingTitle{title: "renamed-title", locked: true} // Simulate a reload (loadSessionsMsg) with the OLD title from disk reloadInst := session.NewInstance("original-name", "/tmp/project") @@ -660,7 +660,7 @@ func TestHomeRenamePendingChangeClearsAutoName(t *testing.T) { // User renamed the session; the rename was stored as a pending change // (save was skipped because isReloading=true at the time) - home.pendingTitleChanges[inst.ID] = "my-chosen-name" + home.pendingTitleChanges[inst.ID] = pendingTitle{title: "my-chosen-name", locked: true} // A reload replaces the instance with the stale disk version (AutoName=true, old title) reloadInst := session.NewInstance("quick-adjective-noun", "/tmp/project") @@ -701,7 +701,7 @@ func TestHomeRenamePendingChangesNoop(t *testing.T) { home.rebuildFlatItems() // Store a pending change that matches the current title (normal save succeeded) - home.pendingTitleChanges[inst.ID] = "desired-name" + home.pendingTitleChanges[inst.ID] = pendingTitle{title: "desired-name", locked: true} // Reload with data that already has the correct title reloadInst := session.NewInstance("desired-name", "/tmp/project") @@ -726,6 +726,83 @@ func TestHomeRenamePendingChangesNoop(t *testing.T) { } } +// TestHomeRenamePendingChangeRestoresTitleLock pins the #697 regression: a user +// rename re-applied after a reload race must come back LOCKED, otherwise the +// next #572 Claude-name sync reverts it to the cwd-folder default (the "my +// rename keeps disappearing on restart" bug). A sync-sourced pending title must +// stay UNLOCKED so it keeps tracking Claude's session name. +func TestHomeRenamePendingChangeRestoresTitleLock(t *testing.T) { + t.Run("user rename is relocked", func(t *testing.T) { + home := NewHome() + home.width = 100 + home.height = 30 + + inst := session.NewInstance("original-name", "/tmp/project") + inst.TitleLocked = true + home.instancesMu.Lock() + home.instances = []*session.Instance{inst} + home.instanceByID[inst.ID] = inst + home.instancesMu.Unlock() + home.groupTree = session.NewGroupTree(home.instances) + home.rebuildFlatItems() + + // User rename queued as locked (SetField sets TitleLocked=true). + home.pendingTitleChanges[inst.ID] = pendingTitle{title: "renamed-title", locked: true} + + // Reload replaces the instance with a stale disk row: old title AND + // UNLOCKED, because the lock was never persisted (save was skipped). + reloadInst := session.NewInstance("original-name", "/tmp/project") + reloadInst.ID = inst.ID + reloadInst.TitleLocked = false + + model, _ := home.Update(loadSessionsMsg{ + instances: []*session.Instance{reloadInst}, + restoreState: &reloadState{cursorSessionID: inst.ID}, + }) + h := model.(*Home) + + if h.instances[0].Title != "renamed-title" { + t.Fatalf("Title = %q, want renamed-title", h.instances[0].Title) + } + if !h.instances[0].TitleLocked { + t.Error("TitleLocked = false after reapply, want true (#697: else the next Claude-name sync reverts the rename)") + } + }) + + t.Run("sync-sourced title stays unlocked", func(t *testing.T) { + home := NewHome() + home.width = 100 + home.height = 30 + + inst := session.NewInstance("proj-ab", "/tmp/project") + home.instancesMu.Lock() + home.instances = []*session.Instance{inst} + home.instanceByID[inst.ID] = inst + home.instancesMu.Unlock() + home.groupTree = session.NewGroupTree(home.instances) + home.rebuildFlatItems() + + // An attach-time Claude-name sync queues an UNLOCKED title. + home.pendingTitleChanges[inst.ID] = pendingTitle{title: "claude-name", locked: false} + + reloadInst := session.NewInstance("proj-ab", "/tmp/project") + reloadInst.ID = inst.ID + + model, _ := home.Update(loadSessionsMsg{ + instances: []*session.Instance{reloadInst}, + restoreState: &reloadState{cursorSessionID: inst.ID}, + }) + h := model.(*Home) + + if h.instances[0].Title != "claude-name" { + t.Fatalf("Title = %q, want claude-name", h.instances[0].Title) + } + if h.instances[0].TitleLocked { + t.Error("TitleLocked = true after sync reapply, want false (sync titles must keep tracking Claude)") + } + }) +} + func TestHomeGlobalSearchInitialized(t *testing.T) { home := NewHome() if home.globalSearch == nil { diff --git a/internal/ui/tui_eval_seam_b_test.go b/internal/ui/tui_eval_seam_b_test.go index 15d313731..3156897f9 100644 --- a/internal/ui/tui_eval_seam_b_test.go +++ b/internal/ui/tui_eval_seam_b_test.go @@ -183,7 +183,7 @@ func seamBNewHome() *Home { hotkeyLookup: make(map[string]string), blockedHotkeys: make(map[string]bool), boundKeys: make(map[string]string), - pendingTitleChanges: make(map[string]string), + pendingTitleChanges: make(map[string]pendingTitle), clearOnCompactSent: make(map[string]time.Time), lastPersistedStatus: make(map[string]string), width: 140, From e5875c25317da24a11444a90df151a5ea4b1ec72 Mon Sep 17 00:00:00 2001 From: Pablo Siber Date: Sun, 5 Jul 2026 18:33:37 +0000 Subject: [PATCH 2/2] review: guard group-dialog rename with instancesMu + document RemoteSession N/A Addresses CodeRabbit review on #1570: - Hold h.instancesMu around the group-dialog SetField mutation (mirrors the edit-dialog rename path); run the tmux-heavy postCommit after unlocking so a slow subprocess can't stall the status worker / reconciler. - Document why the new lock-reapply test needs no RemoteSession case (pendingTitleChanges resolves via getInstanceByID, local instances only). --- internal/ui/home.go | 19 ++++++++++++++++--- internal/ui/home_test.go | 5 +++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/ui/home.go b/internal/ui/home.go index 6da362c17..9a59de067 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -9708,13 +9708,26 @@ func (h *Home) handleGroupDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // SetField so the rename also sets TitleLocked — a direct // Title assignment would be reverted by the #572 // Claude-name sync on the next hook event. + // Mutate under instancesMu to match the edit-dialog rename + // path (SetField writes inst.Title/TitleLocked, which the + // status worker and reconciler read concurrently). Run the + // tmux-heavy postCommit after unlocking so a slow subprocess + // can't stall those readers. locked := true // SetField(FieldTitle) locks; default for the nil-inst path + var postCommit func() + var setErr error + h.instancesMu.Lock() if inst := h.getInstanceByID(sessionID); inst != nil { - if _, _, err := session.SetField(inst, session.FieldTitle, newName, nil); err != nil { - h.setError(err) - } + _, postCommit, setErr = session.SetField(inst, session.FieldTitle, newName, nil) locked = inst.TitleLocked } + h.instancesMu.Unlock() + if setErr != nil { + h.setError(setErr) + } + if postCommit != nil { + postCommit() + } // Store pending title change so it survives reload races. // If saveInstances() is skipped (isReloading=true), the reload // replaces h.instances from disk, losing the in-memory rename diff --git a/internal/ui/home_test.go b/internal/ui/home_test.go index 0ddd0795f..2854e6bd5 100644 --- a/internal/ui/home_test.go +++ b/internal/ui/home_test.go @@ -732,6 +732,11 @@ func TestHomeRenamePendingChangesNoop(t *testing.T) { // rename keeps disappearing on restart" bug). A sync-sourced pending title must // stay UNLOCKED so it keeps tracking Claude's session name. func TestHomeRenamePendingChangeRestoresTitleLock(t *testing.T) { + // No RemoteSession case: pendingTitleChanges is keyed by session ID and + // resolved through getInstanceByID, which only returns local + // *session.Instance objects. A RemoteSession has no local instance and is + // renamed via its own SSH-runner branch in handleGroupDialogKey, so it can + // never reach this reload-reapply path. t.Run("user rename is relocked", func(t *testing.T) { home := NewHome() home.width = 100