Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
73 changes: 56 additions & 17 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,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}
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 +9963,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 +11741,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
88 changes: 85 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,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 {
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