diff --git a/docs/changes/unreleased/1951-cold-team-tab-navigation.md b/docs/changes/unreleased/1951-cold-team-tab-navigation.md new file mode 100644 index 0000000000..e68f3e5563 --- /dev/null +++ b/docs/changes/unreleased/1951-cold-team-tab-navigation.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: Keep team navigation responsive while opening a worker for the first time +pr: 1951 +surface: [chat, docs] +invalidates: + - "Opening an unseen team chat previously blocked input; independent chat opens now run in the background." +--- + +The manager and worker tabs remain usable while a first connection waits on the +engine. Canceling or selecting another chat prevents a late opening from taking +over the selected conversation. Already-open chats keep their immediate switch. +Connections limited to a single shared conversation retain their existing swap +behavior. diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 879118479d..1b3770385f 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -977,3 +977,11 @@ with the error, so your message is not lost or sent to the old chat. Returning to an already open chat keeps its last displayed text visible while its current history refreshes. The refreshed history replaces that preview. + +## Switching to a team chat that is not open yet + +A team tab can name a manager or worker that this window has not opened yet. +In windows that can keep several chats open, its connection opens in the +background, with an opening notice on the current chat. You can select another tab or cancel with Escape while the engine is +busy. A canceled opening does not replace the selected chat when it finishes. +Chats already open in this window switch directly. diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 918c607111..42b42f592a 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1424,8 +1424,8 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) { // "that folder is gone" is two features to keep in step. func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { defer func() { - if t, ok := a.teamActive(); ok && !a.wall.on && !a.teamOverlayHolds(t, a.frontTabKey()) { - a.teamViewSet("") + if !a.conversationOpening { + a.tabSettleTeam() } }() if tab.work { @@ -1456,6 +1456,14 @@ func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { return a.hopStart(hopRow{file: tab.file, where: tab.where, title: tab.word}) } +// tabSettleTeam validates the overlay against the conversation actually selected, +// after an immediate switch or after an asynchronous opening has settled. +func (a *app) tabSettleTeam() { + if t, ok := a.teamActive(); ok && !a.wall.on && !a.teamOverlayHolds(t, a.frontTabKey()) { + a.teamViewSet("") + } +} + // ── DISMISSING A TAB ──────────────────────────────────────────────────────── // tabDismiss removes a conversation's tab from this window. Its transcript and diff --git a/internal/tui3/conversation_open.go b/internal/tui3/conversation_open.go index cdd06fcd0c..3e46c2ed63 100644 --- a/internal/tui3/conversation_open.go +++ b/internal/tui3/conversation_open.go @@ -152,6 +152,9 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } func (a *app) cancelConversationOpening() { + if a.conversationPreview == nil { + defer a.tabSettleTeam() + } a.restoreOpeningDraft(a.conversationPreview) a.conversationOpening = false a.conversationPreview = nil diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 87ade6d932..cca21833d1 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -1,6 +1,7 @@ package tui3 import ( + "errors" "path/filepath" "sort" "strings" @@ -1222,6 +1223,30 @@ func (a *app) hopStart(row hopRow) tea.Cmd { a.hopSay(WorkspaceGoneWord + " · " + row.where) return nil } + if !a.shared && a.open != nil { + if cmd, ours := a.bringForward(row.file); ours { + return a.hopLand(cmd) + } + // A team's visible tab may not have a connection in this window yet. + // Opening it can wait on the engine; keep later clicks and typing live. + // Shared handles retain their existing swap contract because cancelling + // a swap cannot safely release the returned handle independently. + open := a.open + return a.conversationLater(func() (Conversation, error) { + return open(row.where, row.file) + }, a.hopSay, func(conv Conversation) tea.Cmd { + defer a.tabSettleTeam() + return a.hopLand(a.takeBeside(conv)) + }, func(err error) tea.Cmd { + defer a.tabSettleTeam() + if errors.Is(err, session.ErrSessionLocked) { + a.hopSay(sessionBusyWord) + } else { + a.hopSay(err.Error()) + } + return nil + }) + } cmd, refusal := a.openBeside(row.where, row.file) if refusal != "" { a.hopSay(refusal) diff --git a/internal/tui3/teamoverlay.go b/internal/tui3/teamoverlay.go index 5c66cdfd6b..dd6689a0c2 100644 --- a/internal/tui3/teamoverlay.go +++ b/internal/tui3/teamoverlay.go @@ -93,7 +93,7 @@ func (a *app) teamOverlaySync() tea.Cmd { } a.teamViews.pending = target.key cmd := a.tabGo(target) - if a.frontTabKey() != target.key { + if !a.conversationOpening && a.frontTabKey() != target.key { a.teamViewSet("") } return cmd