Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1897-task-chat-tab-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions internal/tui3/chattabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/tui3/planroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions internal/tui3/tabclosekey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions internal/tui3/worktab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions internal/tui3/worktab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading