Skip to content

Commit 133e263

Browse files
andyrewleeclaude
andauthored
Fix tab ordering across multiple instances (#143)
Pre-create tabs as placeholders synchronously in persisted order, then attach to tmux sessions asynchronously via the existing reattach pattern. This ensures tabs always appear in the same order regardless of which goroutine completes first. - Add addPlaceholderTab() and reattachToSession() helpers - Replace createAgentTabWithSession in RestoreTabsFromWorkspace/AddTabsFromWorkspace - Sort discovered tmux tabs by @amux_created_at timestamp - Persist CreatedAt in TabInfo for stable ordering across restarts - Fix scrollback prepend for placeholder tabs on reattach Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d43fcb6 commit 133e263

7 files changed

Lines changed: 229 additions & 61 deletions

File tree

internal/app/app_tmux_discover.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package app
22

33
import (
4+
"sort"
5+
"strconv"
46
"strings"
57

68
tea "charm.land/bubbletea/v2"
@@ -39,7 +41,7 @@ func (a *App) discoverWorkspaceTabsFromTmux(ws *data.Workspace) tea.Cmd {
3941
"@amux_workspace": wsID,
4042
"@amux_type": "agent",
4143
}
42-
rows, err := tmux.SessionsWithTags(match, []string{"@amux_assistant"}, opts)
44+
rows, err := tmux.SessionsWithTags(match, []string{"@amux_assistant", "@amux_created_at"}, opts)
4345
if err != nil {
4446
logging.Warn("tmux session discovery failed: %v", err)
4547
return nil
@@ -60,13 +62,31 @@ func (a *App) discoverWorkspaceTabsFromTmux(ws *data.Workspace) tea.Cmd {
6062
if name == "" {
6163
name = "agent"
6264
}
65+
var createdAt int64
66+
if raw := strings.TrimSpace(row.Tags["@amux_created_at"]); raw != "" {
67+
createdAt, _ = strconv.ParseInt(raw, 10, 64)
68+
}
6369
tabs = append(tabs, data.TabInfo{
6470
Assistant: assistantName,
6571
Name: name,
6672
SessionName: row.Name,
6773
Status: "running",
74+
CreatedAt: createdAt,
6875
})
6976
}
77+
sort.Slice(tabs, func(i, j int) bool {
78+
ci, cj := tabs[i].CreatedAt, tabs[j].CreatedAt
79+
if ci == 0 && cj == 0 {
80+
return false
81+
}
82+
if ci == 0 {
83+
return false // zero sorts last
84+
}
85+
if cj == 0 {
86+
return true
87+
}
88+
return ci < cj
89+
})
7090
if len(tabs) == 0 {
7191
return nil
7292
}

internal/data/workspace.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type TabInfo struct {
3434
Name string `json:"name"`
3535
SessionName string `json:"session_name,omitempty"`
3636
Status string `json:"status,omitempty"`
37+
CreatedAt int64 `json:"created_at,omitempty"`
3738
}
3839

3940
// ScriptsConfig holds the setup/run/archive script commands

internal/ui/center/model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ type Tab struct {
8686
cachedShowCursor bool
8787
monitorSnapAt time.Time
8888
monitorDirty bool
89+
90+
createdAt int64 // Unix timestamp for ordering; persisted in workspace.json
8991
}
9092

9193
func (t *Tab) isClosed() bool {

internal/ui/center/model_input_lifecycle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (m *Model) updatePtyTabReattachResult(msg ptyTabReattachResult) (*Model, te
5050
}
5151
if tab.Terminal != nil {
5252
tab.Terminal.AllowAltScreenScrollback = true
53-
if createdTerminal {
53+
if createdTerminal || len(tab.Terminal.Scrollback) == 0 {
5454
tab.Terminal.PrependScrollback(msg.ScrollbackCapture)
5555
}
5656
}

internal/ui/center/model_tabs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ func (m *Model) handlePtyTabCreated(msg ptyTabCreateResult) tea.Cmd {
183183
Terminal: term,
184184
Running: true, // Agent/viewer starts running
185185
monitorDirty: true,
186+
createdAt: time.Now().Unix(),
186187
}
187188

188189
// Set up response writer for terminal queries (DSR, DA, etc.)
@@ -423,6 +424,7 @@ func (m *Model) GetTabsInfo() ([]data.TabInfo, int) {
423424
Name: tab.Name,
424425
SessionName: sessionName,
425426
Status: status,
427+
CreatedAt: tab.createdAt,
426428
})
427429
}
428430
return result, m.getActiveTabIdx()
@@ -455,6 +457,7 @@ func (m *Model) GetTabsInfoForWorkspace(wsID string) ([]data.TabInfo, int) {
455457
Name: tab.Name,
456458
SessionName: sessionName,
457459
Status: status,
460+
CreatedAt: tab.createdAt,
458461
})
459462
}
460463
return result, m.activeTabByWorkspace[wsID]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package center
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
tea "charm.land/bubbletea/v2"
9+
10+
"github.com/andyrewlee/amux/internal/data"
11+
appPty "github.com/andyrewlee/amux/internal/pty"
12+
"github.com/andyrewlee/amux/internal/tmux"
13+
"github.com/andyrewlee/amux/internal/vterm"
14+
)
15+
16+
func (m *Model) addDetachedTab(ws *data.Workspace, info data.TabInfo) {
17+
tm := m.terminalMetrics()
18+
termWidth := tm.Width
19+
termHeight := tm.Height
20+
if termWidth < 1 {
21+
termWidth = 80
22+
}
23+
if termHeight < 1 {
24+
termHeight = 24
25+
}
26+
displayName := strings.TrimSpace(info.Name)
27+
if displayName == "" {
28+
displayName = strings.TrimSpace(info.Assistant)
29+
}
30+
if displayName == "" {
31+
displayName = "Terminal"
32+
}
33+
term := vterm.New(termWidth, termHeight)
34+
term.AllowAltScreenScrollback = true
35+
ca := info.CreatedAt
36+
if ca == 0 {
37+
ca = time.Now().Unix()
38+
}
39+
tab := &Tab{
40+
ID: generateTabID(),
41+
Name: displayName,
42+
Assistant: info.Assistant,
43+
Workspace: ws,
44+
SessionName: info.SessionName,
45+
Detached: true,
46+
Running: false,
47+
Terminal: term,
48+
createdAt: ca,
49+
}
50+
wsID := string(ws.ID())
51+
m.tabsByWorkspace[wsID] = append(m.tabsByWorkspace[wsID], tab)
52+
}
53+
54+
// addPlaceholderTab synchronously creates a placeholder tab in the correct slice
55+
// position. The tab starts detached and non-running; an async reattach upgrades
56+
// it in-place (by TabID) without changing slice order.
57+
func (m *Model) addPlaceholderTab(ws *data.Workspace, info data.TabInfo) (TabID, string) {
58+
tm := m.terminalMetrics()
59+
termWidth := tm.Width
60+
termHeight := tm.Height
61+
if termWidth < 1 {
62+
termWidth = 80
63+
}
64+
if termHeight < 1 {
65+
termHeight = 24
66+
}
67+
displayName := strings.TrimSpace(info.Name)
68+
if displayName == "" {
69+
displayName = strings.TrimSpace(info.Assistant)
70+
}
71+
if displayName == "" {
72+
displayName = "Terminal"
73+
}
74+
term := vterm.New(termWidth, termHeight)
75+
term.AllowAltScreenScrollback = true
76+
tabID := generateTabID()
77+
sessionName := strings.TrimSpace(info.SessionName)
78+
if sessionName == "" {
79+
sessionName = tmux.SessionName("amux", string(ws.ID()), string(tabID))
80+
}
81+
ca := info.CreatedAt
82+
if ca == 0 {
83+
ca = time.Now().Unix()
84+
}
85+
tab := &Tab{
86+
ID: tabID,
87+
Name: displayName,
88+
Assistant: info.Assistant,
89+
Workspace: ws,
90+
SessionName: sessionName,
91+
Detached: true,
92+
Running: false,
93+
Terminal: term,
94+
createdAt: ca,
95+
}
96+
wsID := string(ws.ID())
97+
m.tabsByWorkspace[wsID] = append(m.tabsByWorkspace[wsID], tab)
98+
return tabID, sessionName
99+
}
100+
101+
// reattachToSession returns a tea.Cmd that asynchronously connects a placeholder
102+
// tab to its tmux session. On success it produces ptyTabReattachResult which
103+
// updates the tab in-place (by TabID). On failure it produces ptyTabReattachFailed.
104+
func (m *Model) reattachToSession(ws *data.Workspace, tabID TabID, assistant string, sessionName string) tea.Cmd {
105+
tm := m.terminalMetrics()
106+
termWidth := tm.Width
107+
termHeight := tm.Height
108+
opts := m.getTmuxOptions()
109+
return func() tea.Msg {
110+
state, err := tmux.SessionStateFor(sessionName, opts)
111+
if err != nil {
112+
return ptyTabReattachFailed{
113+
WorkspaceID: string(ws.ID()),
114+
TabID: tabID,
115+
Err: err,
116+
Action: "reattach",
117+
}
118+
}
119+
if !state.Exists || !state.HasLivePane {
120+
return ptyTabReattachFailed{
121+
WorkspaceID: string(ws.ID()),
122+
TabID: tabID,
123+
Err: fmt.Errorf("tmux session ended"),
124+
Stopped: true,
125+
Action: "reattach",
126+
}
127+
}
128+
tags := tmux.SessionTags{
129+
WorkspaceID: string(ws.ID()),
130+
TabID: string(tabID),
131+
Type: "agent",
132+
Assistant: assistant,
133+
}
134+
agent, err := m.agentManager.CreateAgentWithTags(ws, appPty.AgentType(assistant), sessionName, uint16(termHeight), uint16(termWidth), tags)
135+
if err != nil {
136+
return ptyTabReattachFailed{
137+
WorkspaceID: string(ws.ID()),
138+
TabID: tabID,
139+
Err: err,
140+
Action: "reattach",
141+
}
142+
}
143+
scrollback, _ := tmux.CapturePane(sessionName, opts)
144+
return ptyTabReattachResult{
145+
WorkspaceID: string(ws.ID()),
146+
TabID: tabID,
147+
Agent: agent,
148+
Rows: termHeight,
149+
Cols: termWidth,
150+
ScrollbackCapture: scrollback,
151+
}
152+
}
153+
}
154+
155+
// safeBatch wraps commands in a batch, handling nil commands gracefully.
156+
func safeBatch(cmds ...tea.Cmd) tea.Cmd {
157+
var valid []tea.Cmd
158+
for _, cmd := range cmds {
159+
if cmd != nil {
160+
valid = append(valid, cmd)
161+
}
162+
}
163+
if len(valid) == 0 {
164+
return nil
165+
}
166+
if len(valid) == 1 {
167+
return valid[0]
168+
}
169+
return tea.Batch(valid...)
170+
}

internal/ui/center/model_tabs_session.go

Lines changed: 31 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package center
22

33
import (
4-
"fmt"
54
"strings"
65
"time"
76

@@ -11,7 +10,6 @@ import (
1110
"github.com/andyrewlee/amux/internal/messages"
1211
appPty "github.com/andyrewlee/amux/internal/pty"
1312
"github.com/andyrewlee/amux/internal/tmux"
14-
"github.com/andyrewlee/amux/internal/vterm"
1513
)
1614

1715
// detachTab is the core implementation for detaching a tab (closes PTY, keeps tmux session).
@@ -157,12 +155,34 @@ func (m *Model) ReattachActiveTab() tea.Cmd {
157155
}
158156
}
159157
if !state.Exists || !state.HasLivePane {
160-
return ptyTabReattachFailed{
158+
if state.Exists && !state.HasLivePane {
159+
_ = tmux.KillSession(sessionName, opts)
160+
}
161+
tags := tmux.SessionTags{
161162
WorkspaceID: string(ws.ID()),
162-
TabID: tabID,
163-
Err: fmt.Errorf("tmux session ended"),
164-
Stopped: true,
165-
Action: "reattach",
163+
TabID: string(tabID),
164+
Type: "agent",
165+
Assistant: assistant,
166+
CreatedAt: time.Now().Unix(),
167+
}
168+
agent, err := m.agentManager.CreateAgentWithTags(ws, appPty.AgentType(assistant), sessionName, uint16(termHeight), uint16(termWidth), tags)
169+
if err != nil {
170+
return ptyTabReattachFailed{
171+
WorkspaceID: string(ws.ID()),
172+
TabID: tabID,
173+
Err: err,
174+
Stopped: true,
175+
Action: "reattach",
176+
}
177+
}
178+
scrollback, _ := tmux.CapturePane(sessionName, opts)
179+
return ptyTabReattachResult{
180+
WorkspaceID: string(ws.ID()),
181+
TabID: tabID,
182+
Agent: agent,
183+
Rows: termHeight,
184+
Cols: termWidth,
185+
ScrollbackCapture: scrollback,
166186
}
167187
}
168188
tags := tmux.SessionTags{
@@ -338,7 +358,8 @@ func (m *Model) RestoreTabsFromWorkspace(ws *data.Workspace) tea.Cmd {
338358
continue
339359
}
340360
restoreCount++
341-
cmds = append(cmds, m.createAgentTabWithSession(tab.Assistant, ws, tab.SessionName, tab.Name, false))
361+
tabID, sessionName := m.addPlaceholderTab(ws, tab)
362+
cmds = append(cmds, m.reattachToSession(ws, tabID, tab.Assistant, sessionName))
342363
}
343364
if restoreCount > 0 {
344365
desired := lastBeforeActive
@@ -399,57 +420,8 @@ func (m *Model) AddTabsFromWorkspace(ws *data.Workspace, tabs []data.TabInfo) te
399420
m.addDetachedTab(ws, tab)
400421
continue
401422
}
402-
cmds = append(cmds, m.createAgentTabWithSession(tab.Assistant, ws, sessionName, tab.Name, false))
423+
tabID, sn := m.addPlaceholderTab(ws, tab)
424+
cmds = append(cmds, m.reattachToSession(ws, tabID, tab.Assistant, sn))
403425
}
404426
return safeBatch(cmds...)
405427
}
406-
407-
func (m *Model) addDetachedTab(ws *data.Workspace, info data.TabInfo) {
408-
tm := m.terminalMetrics()
409-
termWidth := tm.Width
410-
termHeight := tm.Height
411-
if termWidth < 1 {
412-
termWidth = 80
413-
}
414-
if termHeight < 1 {
415-
termHeight = 24
416-
}
417-
displayName := strings.TrimSpace(info.Name)
418-
if displayName == "" {
419-
displayName = strings.TrimSpace(info.Assistant)
420-
}
421-
if displayName == "" {
422-
displayName = "Terminal"
423-
}
424-
term := vterm.New(termWidth, termHeight)
425-
term.AllowAltScreenScrollback = true
426-
tab := &Tab{
427-
ID: generateTabID(),
428-
Name: displayName,
429-
Assistant: info.Assistant,
430-
Workspace: ws,
431-
SessionName: info.SessionName,
432-
Detached: true,
433-
Running: false,
434-
Terminal: term,
435-
}
436-
wsID := string(ws.ID())
437-
m.tabsByWorkspace[wsID] = append(m.tabsByWorkspace[wsID], tab)
438-
}
439-
440-
// safeBatch wraps commands in a batch, handling nil commands gracefully.
441-
func safeBatch(cmds ...tea.Cmd) tea.Cmd {
442-
var valid []tea.Cmd
443-
for _, cmd := range cmds {
444-
if cmd != nil {
445-
valid = append(valid, cmd)
446-
}
447-
}
448-
if len(valid) == 0 {
449-
return nil
450-
}
451-
if len(valid) == 1 {
452-
return valid[0]
453-
}
454-
return tea.Batch(valid...)
455-
}

0 commit comments

Comments
 (0)