diff --git a/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md b/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md new file mode 100644 index 000000000..44aa815ae --- /dev/null +++ b/docs/changes/unreleased/1897-task-chat-tab-lifecycle.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: task tabs close without replacing or trapping their main chat +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." +--- + +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 91b392c1a..30ecc22a3 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 b3382bdd6..ea8ee3aa2 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 3345e768b..8251ea049 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 934a1a1a2..e71484182 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 79275b347..f98fa1756 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 ef73d57a5..b711a7dd5 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") + } +}