diff --git a/CHANGELOG.md b/CHANGELOG.md index 3896e488..8ca56b55 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 9d85fa3f..d47b294f 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 // Pending group operations: survive the save-abort → reload race. // Group create/rename/move persist via the non-force saveInstances(), @@ -1152,7 +1165,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, } @@ -4701,21 +4714,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() } @@ -9461,7 +9482,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() @@ -9830,16 +9851,31 @@ 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. - // 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() @@ -10076,7 +10112,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) } // Clear pending group ops on successful save: the whole in-memory // tree (including every recorded op's mutation) was just persisted, @@ -11881,7 +11917,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 673dc2a8..2854e6bd 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,88 @@ 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) { + // 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 + 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 15d31373..3156897f 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,