Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 40 additions & 14 deletions internal/ui/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Invalidate preview cache since title changed
h.invalidatePreviewCache(sessionID)
h.rebuildFlatItems()
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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()
Expand Down
83 changes: 80 additions & 3 deletions internal/ui/home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/tui_eval_seam_b_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down