From 5fbb90fb809f8a8d674186b5b89fec6b3751e13b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 15:15:10 -0400 Subject: [PATCH 1/3] fix(tui): keep task tab lifecycle separate from conversations --- .../1895-task-chat-tab-lifecycle.md | 12 +++ internal/manual/chat/home.md | 2 + internal/tui3/chattabs.go | 21 ++++- internal/tui3/planroom.go | 2 + internal/tui3/tabclosekey.go | 3 + internal/tui3/worktab.go | 3 + internal/tui3/worktab_test.go | 82 +++++++++++++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 docs/changes/unreleased/1895-task-chat-tab-lifecycle.md diff --git a/docs/changes/unreleased/1895-task-chat-tab-lifecycle.md b/docs/changes/unreleased/1895-task-chat-tab-lifecycle.md new file mode 100644 index 0000000000..c76c80ddc4 --- /dev/null +++ b/docs/changes/unreleased/1895-task-chat-tab-lifecycle.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: task tabs close without replacing or trapping their main chat +pr: 1895 +surface: [chat, docs] +invalidates: + - "A run tab used conversation dismissal and could reappear immediately or be chosen as its owner's next conversation. Task views now dismiss independently, stay hidden until explicitly reopened, and never enter conversation close navigation." +--- + +The task tab's close button and ctrl+w on its selected view return to the main +chat without stopping work. Closing the last main chat goes to Home even when +it has a live run; remembered task views cannot migrate into another chat's tabs. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 91b392c1a7..30ecc22a32 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2450,6 +2450,8 @@ No. Home’s Sessions list and the chats menu keep one row for the conversation, A run the task-belt switch (`CODEAF_TASK_BELT=bash`) drives also has a tab of its own on the strip beside its conversation’s, named after the task the run is working on, for as long as the run works. It is a view inside that conversation: while it is open it is the one tab drawn selected, and a press on the conversation’s tab, a press on the `home` tab, or `esc` leaves it. +The run tab’s close button hides that task view without stopping its work or closing the conversation. `ctrl+w` does the same while that task tab is selected. Opening the task again from its row restores the tab. Closing the main conversation selects another open conversation, or Home when it was the last one; a task view never becomes a substitute conversation. + A task handed to a program such as senior-dev has no tab. It opens inside the conversation’s own tab, as any task does, and `esc`, the conversation’s tab and the `home` tab leave it. ## Why does a closed conversation say another window diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index b3382bdd64..ea8ee3aa26 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -348,7 +348,7 @@ func (a *app) tabList() []chatTab { front := a.frontTabKey() tabs := a.chatTabs[:0] for _, tab := range a.chatTabs { - if tab.start || tab.key == "" || tabsHold(tabs, tab.key) { + if tab.start || tab.work || tab.key == "" || tabsHold(tabs, tab.key) { continue } held := a.behind[tab.key] @@ -1474,6 +1474,9 @@ func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { // about at all: there is nothing to decide, and a question with one useful answer // is friction. func (a *app) tabDismiss(tab chatTab) (cmd tea.Cmd) { + if tab.work { + return a.tabDismissNow(tab) + } if t, ok := a.teamActive(); ok && !a.wall.on && teamHolds(t, tab.key) { a.note("Team tabs stay visible; remove membership from Teams") return nil @@ -1494,6 +1497,20 @@ func (a *app) tabDismiss(tab chatTab) (cmd tea.Cmd) { // tabDismissNow is the act itself, with the question already answered or never // worth asking. Every road to a tab leaving the row ends here. func (a *app) tabDismissNow(tab chatTab) (cmd tea.Cmd) { + if tab.work { + // A task tab is a view of its owner, never another conversation to + // switch to or put on the conversation reopen stack. + if a.tabShut == nil { + a.tabShut = map[string]bool{} + } + a.tabShut[tab.key] = true + if tab.here { + a.closeRoom() + } + a.chatTabBar = tabBar{} + a.touch() + return nil + } if t, ok := a.teamActive(); ok && !a.wall.on && teamHolds(t, tab.key) { return nil } @@ -1542,7 +1559,7 @@ func (a *app) tabDismissNow(tab chatTab) (cmd tea.Cmd) { // tab has not acquired a recency entry yet. func (a *app) lastVisibleTab() (chatTab, bool) { eligible := func(tab chatTab) bool { - return !tab.start && tab.key != "" && tab.key != a.frontTabKey() && !a.tabShut[tab.key] + return !tab.start && !tab.work && tab.key != "" && tab.key != a.frontTabKey() && !a.tabShut[tab.key] } for at := len(a.prev) - 1; at >= 0; at-- { for _, tab := range a.chatTabs { diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go index 3345e768b4..8251ea0496 100644 --- a/internal/tui3/planroom.go +++ b/internal/tui3/planroom.go @@ -74,6 +74,8 @@ func (a *app) openPlanRoom(page session.PlanTaskPage) tea.Cmd { if a.startingChat() { a.parkChatStart() } + // Opening a task explicitly restores its run view after a tab dismissal. + delete(a.tabShut, a.frontTabKey()+"#work") id := strings.TrimSpace(page.Row.ID) title := strings.TrimSpace(page.Row.Title) room := a.newRoom(planRailNodeID(id), title) diff --git a/internal/tui3/tabclosekey.go b/internal/tui3/tabclosekey.go index 934a1a1a27..e714841820 100644 --- a/internal/tui3/tabclosekey.go +++ b/internal/tui3/tabclosekey.go @@ -48,6 +48,9 @@ func (a *app) closeTabKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if a.railHold { a.railTake(false) } + if work, ok := a.workTab(); ok && work.here && !a.startingChat() { + return a.tabDismiss(work), true + } return a.tabDismiss(a.frontChatTab()), true } diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 79275b3471..f98fa17562 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -19,6 +19,9 @@ func (a *app) workTab() (chatTab, bool) { // (programroom.go); a tab of its own drew itself selected beside the // conversation's, and a press on the conversation's tab never left it. Only // the rows of a run the belt switch drives make the tab. + if a.tabShut[a.frontTabKey()+"#work"] { + return chatTab{}, false + } rows := beltRows(a.taskSheet.mine.plan) if len(rows) == 0 { return chatTab{}, false diff --git a/internal/tui3/worktab_test.go b/internal/tui3/worktab_test.go index ef73d57a58..b711a7dd5d 100644 --- a/internal/tui3/worktab_test.go +++ b/internal/tui3/worktab_test.go @@ -177,3 +177,85 @@ func TestARunTabDoesNotDuplicateOrRenameItsConversationLists(t *testing.T) { t.Fatalf("Sessions used the run title %q instead of %q", view.title, a.title) } } + +func TestWorkTabCloseStaysClosedWithoutClosingConversation(t *testing.T) { + for _, inRoom := range []bool{false, true} { + t.Run(map[bool]string{false: "from main", true: "from task"}[inRoom], func(t *testing.T) { + a, _ := workTabFixture(t) + if inRoom { + openWorkTabNow(t, a) + } + frame(a) + var work chatTab + for _, tab := range a.chatTabs { + if tab.work { + work = tab + } + } + drain(t, a, a.tabDismiss(work)) + for i := 0; i < 3; i++ { + frame(a) + if len(a.chatTabs) != 1 || a.chatTabs[0].work || !a.chatTabs[0].here || a.roomOpen() { + t.Fatalf("closing task tab did not leave only its main chat: %+v", a.chatTabs) + } + } + if a.tabShut[a.frontTabKey()] || a.pageShowing() || len(a.closedTabs) != 0 { + t.Fatal("closing a task view dismissed or queued its conversation") + } + }) + } +} + +func TestWorkTabCannotBeTheNextConversationOnClose(t *testing.T) { + a, _ := workTabFixture(t) + frame(a) + if next, ok := a.lastVisibleTab(); ok { + t.Fatalf("task view offered as another conversation: %+v", next) + } +} + +func TestWorkTabDoesNotBecomeARememberedConversation(t *testing.T) { + a, fake := workTabFixture(t) + frame(a) + // Keep the outgoing identity in recency, as a real conversation switch does. + old := a.frontTabKey() + a.prev = append(a.prev, old, old+"#work") + a.file, a.title = "/tmp/lab/chat-2/transcript.jsonl", "second chat" + a.taskSheet.mine.plan, a.planRows, fake.plan = nil, nil, nil + for i := 0; i < 3; i++ { + frame(a) + for _, tab := range a.chatTabs { + if tab.work { + t.Fatalf("outgoing task view persisted as a conversation: %+v", tab) + } + } + } +} + +func TestWorkTabKeyboardCloseAndExplicitReopen(t *testing.T) { + a, _ := workTabFixture(t) + openWorkTabNow(t, a) + frame(a) + drive(t, a, key(closeTabChord)) + frame(a) + if a.roomOpen() || len(a.chatTabs) != 1 || a.tabShut[a.frontTabKey()] { + t.Fatalf("keyboard close did not dismiss just the task view: %+v", a.chatTabs) + } + // The rail remains the explicit way back to the task after closing its tab. + drain(t, a, a.openRailPlan("t-root", nil)) + frame(a) + if !a.roomOpen() || len(a.chatTabs) != 2 || !a.chatTabs[1].work || !a.chatTabs[1].here { + t.Fatalf("opening the task did not restore its tab: %+v", a.chatTabs) + } +} + +func TestWorkTabDoesNotPreventClosingItsLastConversation(t *testing.T) { + a, _ := workTabFixture(t) + frame(a) + owner := a.frontTabKey() + drain(t, a, a.tabDismissNow(a.frontChatTab())) + frame(a) + if !a.at(pageHome) || !a.tabShut[owner] { + t.Fatal("the run tab prevented its last conversation from closing to Home") + } +} From d5ede98879af7819ed54e3857f5bfef2d6e96555 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 15:17:26 -0400 Subject: [PATCH 2/3] docs: link task tab fix to PR 1897 --- ...task-chat-tab-lifecycle.md => 1897-task-chat-tab-lifecycle.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{1895-task-chat-tab-lifecycle.md => 1897-task-chat-tab-lifecycle.md} (100%) diff --git a/docs/changes/unreleased/1895-task-chat-tab-lifecycle.md b/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md similarity index 100% rename from docs/changes/unreleased/1895-task-chat-tab-lifecycle.md rename to docs/changes/unreleased/1897-task-chat-tab-lifecycle.md From 2cf97856aae2682e9a4d3b8d9fc86a5bee9b6323 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 15:17:34 -0400 Subject: [PATCH 3/3] docs: set actual pull request number --- docs/changes/unreleased/1897-task-chat-tab-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md b/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md index c76c80ddc4..44aa815ae8 100644 --- a/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md +++ b/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md @@ -1,7 +1,7 @@ --- kind: fixed title: task tabs close without replacing or trapping their main chat -pr: 1895 +pr: 1897 surface: [chat, docs] invalidates: - "A run tab used conversation dismissal and could reappear immediately or be chosen as its owner's next conversation. Task views now dismiss independently, stay hidden until explicitly reopened, and never enter conversation close navigation."