Skip to content

Commit 522ad7a

Browse files
committed
Fix tab detach persistence and reattach state handling
1 parent 233ff6e commit 522ad7a

13 files changed

Lines changed: 568 additions & 89 deletions

internal/app/app_input.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,8 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) {
216216

217217
case messages.TabDetached:
218218
logging.Info("Tab detached: %d", msg.Index)
219-
if msg.WorkspaceID != "" {
220-
cmds = append(cmds, a.persistWorkspaceTabs(msg.WorkspaceID))
221-
} else {
222-
cmds = append(cmds, a.persistActiveWorkspaceTabs())
219+
if cmd := a.handleTabDetached(msg); cmd != nil {
220+
cmds = append(cmds, cmd)
223221
}
224222

225223
case messages.TabReattached:
@@ -347,3 +345,10 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) {
347345

348346
return a, common.SafeBatch(cmds...)
349347
}
348+
349+
func (a *App) handleTabDetached(msg messages.TabDetached) tea.Cmd {
350+
if msg.WorkspaceID != "" {
351+
return a.persistWorkspaceTabs(msg.WorkspaceID)
352+
}
353+
return a.persistActiveWorkspaceTabs()
354+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
6+
"github.com/andyrewlee/amux/internal/data"
7+
"github.com/andyrewlee/amux/internal/messages"
8+
)
9+
10+
func TestHandleTabDetached_PersistsSourceWorkspace(t *testing.T) {
11+
active := data.NewWorkspace("active", "main", "main", "/repo", "/repo")
12+
activeID := string(active.ID())
13+
sourceWorkspaceID := "ws-source"
14+
15+
app := &App{
16+
activeWorkspace: active,
17+
dirtyWorkspaces: map[string]bool{},
18+
}
19+
20+
cmd := app.handleTabDetached(messages.TabDetached{
21+
WorkspaceID: sourceWorkspaceID,
22+
Index: 3,
23+
})
24+
if cmd == nil {
25+
t.Fatal("expected non-nil persist cmd")
26+
}
27+
if !app.dirtyWorkspaces[sourceWorkspaceID] {
28+
t.Fatalf("expected source workspace %q to be marked dirty", sourceWorkspaceID)
29+
}
30+
if app.dirtyWorkspaces[activeID] {
31+
t.Fatalf("did not expect active workspace %q to be marked dirty", activeID)
32+
}
33+
}
34+
35+
func TestHandleTabDetached_FallsBackToActiveWorkspace(t *testing.T) {
36+
active := data.NewWorkspace("active", "main", "main", "/repo", "/repo")
37+
activeID := string(active.ID())
38+
39+
app := &App{
40+
activeWorkspace: active,
41+
dirtyWorkspaces: map[string]bool{},
42+
}
43+
44+
cmd := app.handleTabDetached(messages.TabDetached{Index: 1})
45+
if cmd == nil {
46+
t.Fatal("expected non-nil persist cmd")
47+
}
48+
if !app.dirtyWorkspaces[activeID] {
49+
t.Fatalf("expected active workspace %q to be marked dirty", activeID)
50+
}
51+
}

internal/ui/center/model_input.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,14 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
165165
}
166166
// Handle ctrl+n/p for tab switching
167167
if key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))) {
168+
before := m.getActiveTabIdx()
168169
m.nextTab()
169-
return m, m.tabSelectionCommand()
170+
return m, m.tabSelectionChangedCmd(m.getActiveTabIdx() != before)
170171
}
171172
if key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+p"))) {
173+
before := m.getActiveTabIdx()
172174
m.prevTab()
173-
return m, m.tabSelectionCommand()
175+
return m, m.tabSelectionChangedCmd(m.getActiveTabIdx() != before)
174176
}
175177
// Forward all other keys to diff viewer
176178
if handled, cmd := m.dispatchDiffInput(tab, msg); handled {
@@ -183,21 +185,24 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
183185
// Only intercept these specific keys - everything else goes to terminal
184186
switch {
185187
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))):
188+
before := m.getActiveTabIdx()
186189
m.nextTab()
187-
return m, m.tabSelectionCommand()
190+
return m, m.tabSelectionChangedCmd(m.getActiveTabIdx() != before)
188191

189192
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+p"))):
193+
before := m.getActiveTabIdx()
190194
m.prevTab()
191-
return m, m.tabSelectionCommand()
195+
return m, m.tabSelectionChangedCmd(m.getActiveTabIdx() != before)
192196

193197
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+w"))):
194198
// Close tab
195199
return m, m.closeCurrentTab()
196200

197201
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+]"))):
198202
// Switch to next tab (escape hatch that won't conflict)
203+
before := m.getActiveTabIdx()
199204
m.nextTab()
200-
return m, m.tabSelectionCommand()
205+
return m, m.tabSelectionChangedCmd(m.getActiveTabIdx() != before)
201206

202207
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+["))):
203208
// This is Escape - let it go to terminal

internal/ui/center/model_input_lifecycle.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,20 +120,19 @@ func (m *Model) updatePtyTabReattachFailed(msg ptyTabReattachFailed) (*Model, te
120120
if tab == nil {
121121
return m, nil
122122
}
123-
action := msg.Action
124-
if action == "" {
125-
action = "reattach"
126-
}
127123
tab.mu.Lock()
128124
tab.Running = false
129125
tab.reattachInFlight = false
130-
// Keep tabs detached after reattach failures so selecting the tab can retry.
131-
// Restart failures still mark the tab as stopped.
132-
if msg.Stopped && action == "restart" {
126+
// Any stopped reattach clears Detached so the tab shows as stopped.
127+
if msg.Stopped {
133128
tab.Detached = false
134129
}
135130
tab.mu.Unlock()
136131
logging.Warn("Reattach failed for tab %s: %v", msg.TabID, msg.Err)
132+
action := msg.Action
133+
if action == "" {
134+
action = "reattach"
135+
}
137136
label := "Reattach"
138137
switch action {
139138
case "restart":
@@ -337,11 +336,16 @@ func (m *Model) updatePTYFlush(msg PTYFlush) tea.Cmd {
337336
if len(tab.pendingOutput) > 0 {
338337
var chunk []byte
339338
writeOutput := false
339+
isActive := m.isActiveTab(msg.WorkspaceID, msg.TabID)
340340
tab.mu.Lock()
341341
if tab.Terminal != nil {
342342
chunkSize := len(tab.pendingOutput)
343-
if chunkSize > ptyFlushChunkSize {
344-
chunkSize = ptyFlushChunkSize
343+
maxChunk := ptyFlushChunkSize
344+
if isActive {
345+
maxChunk = ptyFlushChunkSizeActive
346+
}
347+
if chunkSize > maxChunk {
348+
chunkSize = maxChunk
345349
}
346350
chunk = append(chunk, tab.pendingOutput[:chunkSize]...)
347351
copy(tab.pendingOutput, tab.pendingOutput[chunkSize:])
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package center
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
"github.com/andyrewlee/amux/internal/vterm"
9+
)
10+
11+
func TestUpdatePTYFlush_UsesLargerChunkForActiveTab(t *testing.T) {
12+
m := newTestModel()
13+
ws := newTestWorkspace("ws", "/repo/ws")
14+
wsID := string(ws.ID())
15+
tab := &Tab{
16+
ID: TabID("tab-active"),
17+
Workspace: ws,
18+
Terminal: vterm.New(80, 24),
19+
Running: true,
20+
lastOutputAt: time.Now().Add(-time.Second),
21+
flushPendingSince: time.Now().Add(-time.Second),
22+
pendingOutput: bytes.Repeat([]byte("x"), ptyFlushChunkSizeActive+17),
23+
}
24+
m.tabsByWorkspace[wsID] = []*Tab{tab}
25+
m.activeTabByWorkspace[wsID] = 0
26+
m.workspace = ws
27+
28+
_ = m.updatePTYFlush(PTYFlush{WorkspaceID: wsID, TabID: tab.ID})
29+
30+
if got, want := len(tab.pendingOutput), 17; got != want {
31+
t.Fatalf("pending output = %d, want %d", got, want)
32+
}
33+
}
34+
35+
func TestUpdatePTYFlush_UsesBaseChunkForInactiveTab(t *testing.T) {
36+
m := newTestModel()
37+
ws := newTestWorkspace("ws", "/repo/ws")
38+
wsID := string(ws.ID())
39+
active := &Tab{
40+
ID: TabID("tab-active"),
41+
Workspace: ws,
42+
Terminal: vterm.New(80, 24),
43+
Running: true,
44+
}
45+
inactive := &Tab{
46+
ID: TabID("tab-inactive"),
47+
Workspace: ws,
48+
Terminal: vterm.New(80, 24),
49+
Running: true,
50+
lastOutputAt: time.Now().Add(-time.Second),
51+
flushPendingSince: time.Now().Add(-time.Second),
52+
pendingOutput: bytes.Repeat([]byte("x"), ptyFlushChunkSize+17),
53+
}
54+
m.tabsByWorkspace[wsID] = []*Tab{active, inactive}
55+
m.activeTabByWorkspace[wsID] = 0
56+
m.workspace = ws
57+
58+
_ = m.updatePTYFlush(PTYFlush{WorkspaceID: wsID, TabID: inactive.ID})
59+
60+
if got, want := len(inactive.pendingOutput), 17; got != want {
61+
t.Fatalf("pending output = %d, want %d", got, want)
62+
}
63+
}

internal/ui/center/model_pty_config.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ const (
1717
ptyVeryHeavyLoadTabThreshold = 8
1818
ptyLoadSampleInterval = 100 * time.Millisecond
1919
ptyFlushChunkSize = 32 * 1024
20-
ptyReadBufferSize = 32 * 1024
21-
ptyReadQueueSize = 64
22-
ptyFrameInterval = time.Second / 60
23-
ptyMaxPendingBytes = 512 * 1024
24-
ptyMaxBufferedBytes = 8 * 1024 * 1024
25-
ptyReaderStallTimeout = 10 * time.Second
26-
tabActorStallTimeout = 10 * time.Second
27-
ptyRestartMax = 5
28-
ptyRestartWindow = time.Minute
20+
// Active tab catch-up should drain backlog quickly to avoid visible replay.
21+
ptyFlushChunkSizeActive = 256 * 1024
22+
ptyReadBufferSize = 32 * 1024
23+
ptyReadQueueSize = 64
24+
ptyFrameInterval = time.Second / 60
25+
ptyMaxPendingBytes = 512 * 1024
26+
ptyMaxBufferedBytes = 8 * 1024 * 1024
27+
ptyReaderStallTimeout = 10 * time.Second
28+
tabActorStallTimeout = 10 * time.Second
29+
ptyRestartMax = 5
30+
ptyRestartWindow = time.Minute
2931

3032
// Backpressure thresholds (inspired by tmux's TTY_BLOCK_START/STOP)
3133
// When pending output exceeds this, we throttle rendering frequency

internal/ui/center/model_render_tabbar.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,9 @@ func (m *Model) handleTabBarClick(msg tea.MouseClickMsg) tea.Cmd {
221221
case tabHitPlus:
222222
return func() tea.Msg { return messages.ShowSelectAssistantDialog{} }
223223
case tabHitTab:
224+
before := m.getActiveTabIdx()
224225
m.setActiveTabIdx(hit.index)
225-
return m.tabSelectionCommand()
226+
return m.tabSelectionChangedCmd(hit.index != before)
226227
}
227228
}
228229
}

internal/ui/center/model_tabs_actions.go

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/andyrewlee/amux/internal/logging"
88
"github.com/andyrewlee/amux/internal/messages"
99
"github.com/andyrewlee/amux/internal/tmux"
10-
"github.com/andyrewlee/amux/internal/ui/common"
1110
)
1211

1312
// closeCurrentTab closes the current tab
@@ -159,30 +158,8 @@ func (m *Model) ReattachActiveTabIfDetached() tea.Cmd {
159158
return m.reattachActiveTabIfDetached()
160159
}
161160

162-
func (m *Model) autoReattachActiveTabOnSelection() tea.Cmd {
163-
tabs := m.getTabs()
164-
activeIdx := m.getActiveTabIdx()
165-
if len(tabs) == 0 || activeIdx < 0 || activeIdx >= len(tabs) {
166-
return nil
167-
}
168-
tab := tabs[activeIdx]
169-
if tab == nil {
170-
return nil
171-
}
172-
tab.mu.Lock()
173-
detached := tab.Detached
174-
tab.mu.Unlock()
175-
if !detached {
176-
return nil
177-
}
178-
return m.ReattachActiveTab()
179-
}
180-
181161
func (m *Model) tabSelectionCommand() tea.Cmd {
182-
return common.SafeBatch(
183-
m.tabSelectionChangedCmd(),
184-
m.autoReattachActiveTabOnSelection(),
185-
)
162+
return m.tabSelectionChangedCmd(true)
186163
}
187164

188165
// Public wrappers for prefix mode commands

internal/ui/center/model_tabs_restore.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,14 @@ func (m *Model) addPlaceholderTab(ws *data.Workspace, info data.TabInfo) (TabID,
8484
ca = time.Now().Unix()
8585
}
8686
tab := &Tab{
87-
ID: tabID,
88-
Name: displayName,
89-
Assistant: info.Assistant,
90-
Workspace: ws,
91-
SessionName: sessionName,
92-
Detached: true,
93-
Running: false,
87+
ID: tabID,
88+
Name: displayName,
89+
Assistant: info.Assistant,
90+
Workspace: ws,
91+
SessionName: sessionName,
92+
Detached: true,
93+
Running: false,
94+
// Placeholder tabs are immediately queued for async reattach.
9495
reattachInFlight: true,
9596
Terminal: term,
9697
createdAt: ca,

0 commit comments

Comments
 (0)