fix(daemon): trace tab and pane move outcomes
- commit
- 80df2be5e08ea8cf5a7ec03099a87b263a12e7fa
- author
- brice · · 2026-08-20 08:30 UTC
- parent
- 83b15cd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +135 -4
M internal/usecase/daemon/move.go +25 -2
| @@ -32,8 +32,30 @@ type moveTabRequest struct { | |||
| 32 | 32 | // section starts, the caller holds the ordered architecture locks and only | |
| 33 | 33 | // non-failing in-memory writes are permitted. | |
| 34 | 34 | func (d *Daemon) movePane(req movePaneRequest) (result error) { | |
| 35 | - | defer func() { result = normalizeMoveRejection(result) }() | |
| 36 | - | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | |
| 35 | + | if d == nil { | |
| 36 | + | return errMovePaneInvalid | |
| 37 | + | } | |
| 38 | + | d.log.Info("pane move requested", | |
| 39 | + | "source_session_id", req.Source.ID, | |
| 40 | + | "source_tab_id", req.SourceTabID, | |
| 41 | + | "source_pane_id", req.SourcePaneID, | |
| 42 | + | "destination_session_id", req.Destination.ID, | |
| 43 | + | "destination_tab_id", req.DestinationTabID, | |
| 44 | + | ) | |
| 45 | + | defer func() { | |
| 46 | + | result = normalizeMoveRejection(result) | |
| 47 | + | if result != nil { | |
| 48 | + | d.log.Warn("pane move rejected", | |
| 49 | + | "err", result, | |
| 50 | + | "source_session_id", req.Source.ID, | |
| 51 | + | "source_tab_id", req.SourceTabID, | |
| 52 | + | "source_pane_id", req.SourcePaneID, | |
| 53 | + | "destination_session_id", req.Destination.ID, | |
| 54 | + | "destination_tab_id", req.DestinationTabID, | |
| 55 | + | ) | |
| 56 | + | } | |
| 57 | + | }() | |
| 58 | + | if req.Source.ID == "" || req.Destination.ID == "" || | |
| 37 | 59 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | |
| 38 | 60 | return errMovePaneInvalid | |
| 39 | 61 | } | |
| @@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 150 | 172 | sourceTab: sourceTab, | |
| 151 | 173 | destinationTab: destinationTab, | |
| 152 | 174 | movedPane: movedPane, | |
| 175 | + | operation: "pane", | |
| 153 | 176 | sourceAttachments: admission.sourceAttachments, | |
| 154 | 177 | syncCleanup: commit.syncCleanup, | |
| 155 | 178 | frozenEffects: frozen, | |
| @@ -32,8 +32,30 @@ type moveTabRequest struct { | |||||
| 32 | // section starts, the caller holds the ordered architecture locks and only | 32 | // section starts, the caller holds the ordered architecture locks and only | ||
| 33 | // non-failing in-memory writes are permitted. | 33 | // non-failing in-memory writes are permitted. | ||
| 34 | func (d *Daemon) movePane(req movePaneRequest) (result error) { | 34 | func (d *Daemon) movePane(req movePaneRequest) (result error) { | ||
| 35 | - | defer func() { result = normalizeMoveRejection(result) }() | 35 | + | if d == nil { |
| 36 | - | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | 36 | + | return errMovePaneInvalid |
| 37 | + | } | |||
| 38 | + | d.log.Info("pane move requested", | |||
| 39 | + | "source_session_id", req.Source.ID, | |||
| 40 | + | "source_tab_id", req.SourceTabID, | |||
| 41 | + | "source_pane_id", req.SourcePaneID, | |||
| 42 | + | "destination_session_id", req.Destination.ID, | |||
| 43 | + | "destination_tab_id", req.DestinationTabID, | |||
| 44 | + | ) | |||
| 45 | + | defer func() { | |||
| 46 | + | result = normalizeMoveRejection(result) | |||
| 47 | + | if result != nil { | |||
| 48 | + | d.log.Warn("pane move rejected", | |||
| 49 | + | "err", result, | |||
| 50 | + | "source_session_id", req.Source.ID, | |||
| 51 | + | "source_tab_id", req.SourceTabID, | |||
| 52 | + | "source_pane_id", req.SourcePaneID, | |||
| 53 | + | "destination_session_id", req.Destination.ID, | |||
| 54 | + | "destination_tab_id", req.DestinationTabID, | |||
| 55 | + | ) | |||
| 56 | + | } | |||
| 57 | + | }() | |||
| 58 | + | if req.Source.ID == "" || req.Destination.ID == "" || | |||
| 37 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | 59 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | ||
| 38 | return errMovePaneInvalid | 60 | return errMovePaneInvalid | ||
| 39 | } | 61 | } | ||
| @@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 150 | sourceTab: sourceTab, | 172 | sourceTab: sourceTab, | ||
| 151 | destinationTab: destinationTab, | 173 | destinationTab: destinationTab, | ||
| 152 | movedPane: movedPane, | 174 | movedPane: movedPane, | ||
| 175 | + | operation: "pane", | |||
| 153 | sourceAttachments: admission.sourceAttachments, | 176 | sourceAttachments: admission.sourceAttachments, | ||
| 154 | syncCleanup: commit.syncCleanup, | 177 | syncCleanup: commit.syncCleanup, | ||
| 155 | frozenEffects: frozen, | 178 | frozenEffects: frozen, | ||
A internal/usecase/daemon/move_observability_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "log/slog" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestMoveTabLogsCommitAndSourceAttachmentTermination(t *testing.T) { | |
| 15 | + | var logs bytes.Buffer | |
| 16 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 17 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |
| 18 | + | ||
| 19 | + | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |
| 20 | + | t.Cleanup(destinationCancel) | |
| 21 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 22 | + | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |
| 23 | + | destination := &session{ | |
| 24 | + | sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}}, | |
| 25 | + | ctx: destinationCtx, | |
| 26 | + | cancel: destinationCancel, | |
| 27 | + | tabs: []*tab{destinationTab}, | |
| 28 | + | } | |
| 29 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 30 | + | d.mu.Lock() | |
| 31 | + | d.sessions[destination.id] = destination | |
| 32 | + | d.mu.Unlock() | |
| 33 | + | ||
| 34 | + | sourceTab := source.tabs[0] | |
| 35 | + | sourceTab.stableID = "source-tab" | |
| 36 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 37 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 38 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 39 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 40 | + | })) | |
| 41 | + | d.waitNotifies() | |
| 42 | + | ||
| 43 | + | output := logs.String() | |
| 44 | + | require.Contains(t, output, `"msg":"tab move requested"`) | |
| 45 | + | require.Contains(t, output, `"msg":"move committed"`) | |
| 46 | + | require.Contains(t, output, `"operation":"tab"`) | |
| 47 | + | require.Contains(t, output, `"source_retired":true`) | |
| 48 | + | require.Contains(t, output, `"msg":"move detaching source attachments"`) | |
| 49 | + | require.Contains(t, output, `"reason":"session-killed"`) | |
| 50 | + | require.Contains(t, output, `"msg":"client detached"`) | |
| 51 | + | require.Contains(t, output, `"reason":1`) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestMoveTabLogsNormalizedRejection(t *testing.T) { | |
| 55 | + | var logs bytes.Buffer | |
| 56 | + | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil))) | |
| 57 | + | ||
| 58 | + | err := d.moveTab(moveTabRequest{ | |
| 59 | + | Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}}, | |
| 60 | + | SourceTabID: "tab", | |
| 61 | + | Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}}, | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | require.ErrorIs(t, err, errMovePaneInvalid) | |
| 65 | + | output := logs.String() | |
| 66 | + | require.Contains(t, output, `"msg":"tab move requested"`) | |
| 67 | + | require.Contains(t, output, `"msg":"tab move rejected"`) | |
| 68 | + | require.Contains(t, output, `"source_session_id":"missing"`) | |
| 69 | + | require.Contains(t, output, `"destination_session_id":"also-missing"`) | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestMoveTabLogsCommitAndSourceAttachmentTermination(t *testing.T) { | |||
| 15 | + | var logs bytes.Buffer | |||
| 16 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 17 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |||
| 18 | + | ||||
| 19 | + | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |||
| 20 | + | t.Cleanup(destinationCancel) | |||
| 21 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |||
| 22 | + | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |||
| 23 | + | destination := &session{ | |||
| 24 | + | sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}}, | |||
| 25 | + | ctx: destinationCtx, | |||
| 26 | + | cancel: destinationCancel, | |||
| 27 | + | tabs: []*tab{destinationTab}, | |||
| 28 | + | } | |||
| 29 | + | publishTiledPaneOwners(destination, destinationTab) | |||
| 30 | + | d.mu.Lock() | |||
| 31 | + | d.sessions[destination.id] = destination | |||
| 32 | + | d.mu.Unlock() | |||
| 33 | + | ||||
| 34 | + | sourceTab := source.tabs[0] | |||
| 35 | + | sourceTab.stableID = "source-tab" | |||
| 36 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 37 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 38 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |||
| 39 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 40 | + | })) | |||
| 41 | + | d.waitNotifies() | |||
| 42 | + | ||||
| 43 | + | output := logs.String() | |||
| 44 | + | require.Contains(t, output, `"msg":"tab move requested"`) | |||
| 45 | + | require.Contains(t, output, `"msg":"move committed"`) | |||
| 46 | + | require.Contains(t, output, `"operation":"tab"`) | |||
| 47 | + | require.Contains(t, output, `"source_retired":true`) | |||
| 48 | + | require.Contains(t, output, `"msg":"move detaching source attachments"`) | |||
| 49 | + | require.Contains(t, output, `"reason":"session-killed"`) | |||
| 50 | + | require.Contains(t, output, `"msg":"client detached"`) | |||
| 51 | + | require.Contains(t, output, `"reason":1`) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestMoveTabLogsNormalizedRejection(t *testing.T) { | |||
| 55 | + | var logs bytes.Buffer | |||
| 56 | + | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil))) | |||
| 57 | + | ||||
| 58 | + | err := d.moveTab(moveTabRequest{ | |||
| 59 | + | Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}}, | |||
| 60 | + | SourceTabID: "tab", | |||
| 61 | + | Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}}, | |||
| 62 | + | }) | |||
| 63 | + | ||||
| 64 | + | require.ErrorIs(t, err, errMovePaneInvalid) | |||
| 65 | + | output := logs.String() | |||
| 66 | + | require.Contains(t, output, `"msg":"tab move requested"`) | |||
| 67 | + | require.Contains(t, output, `"msg":"tab move rejected"`) | |||
| 68 | + | require.Contains(t, output, `"source_session_id":"missing"`) | |||
| 69 | + | require.Contains(t, output, `"destination_session_id":"also-missing"`) | |||
| 70 | + | } | |||
M internal/usecase/daemon/move_pane_postcommit.go +19 -0
| @@ -26,6 +26,7 @@ type movePanePostcommitPlan struct { | |||
| 26 | 26 | destinationTab *tab | |
| 27 | 27 | movedPane *pane | |
| 28 | 28 | movedPanes []*pane | |
| 29 | + | operation string | |
| 29 | 30 | sourceAttachments []*attachedClient | |
| 30 | 31 | syncCleanup syncTimerCleanup | |
| 31 | 32 | frozenEffects frozenAttachmentEffectGates | |
| @@ -79,6 +80,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 79 | 80 | } | |
| 80 | 81 | p.unlockDispatch() | |
| 81 | 82 | p.reservation.Release() | |
| 83 | + | d.log.Info("move committed", | |
| 84 | + | "operation", p.operation, | |
| 85 | + | "source_session", p.source.name, | |
| 86 | + | "source_session_id", p.source.id, | |
| 87 | + | "source_tab_id", p.sourceTab.stableID, | |
| 88 | + | "destination_session", p.destination.name, | |
| 89 | + | "destination_session_id", p.destination.id, | |
| 90 | + | "source_retired", p.sourceEmpty, | |
| 91 | + | ) | |
| 82 | 92 | ||
| 83 | 93 | // Repair attachment-local stable targets after shared topology publication | |
| 84 | 94 | // and after the move dispatch locks have been released. | |
| @@ -114,6 +124,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 114 | 124 | if p.sourceEmpty { | |
| 115 | 125 | retireEmptySessionAfterMove(d, p.source) | |
| 116 | 126 | } | |
| 127 | + | if len(p.retiredAttachments) != 0 { | |
| 128 | + | d.log.Info("move detaching source attachments", | |
| 129 | + | "operation", p.operation, | |
| 130 | + | "source_session", p.source.name, | |
| 131 | + | "source_session_id", p.source.id, | |
| 132 | + | "reason", "session-killed", | |
| 133 | + | "attachments", len(p.retiredAttachments), | |
| 134 | + | ) | |
| 135 | + | } | |
| 117 | 136 | for _, attachment := range p.retiredAttachments { | |
| 118 | 137 | d.unregisterPreview(attachment.ac) | |
| 119 | 138 | attachment.ac.clearCaptureFrames() | |
| @@ -26,6 +26,7 @@ type movePanePostcommitPlan struct { | |||||
| 26 | destinationTab *tab | 26 | destinationTab *tab | ||
| 27 | movedPane *pane | 27 | movedPane *pane | ||
| 28 | movedPanes []*pane | 28 | movedPanes []*pane | ||
| 29 | + | operation string | |||
| 29 | sourceAttachments []*attachedClient | 30 | sourceAttachments []*attachedClient | ||
| 30 | syncCleanup syncTimerCleanup | 31 | syncCleanup syncTimerCleanup | ||
| 31 | frozenEffects frozenAttachmentEffectGates | 32 | frozenEffects frozenAttachmentEffectGates | ||
| @@ -79,6 +80,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 79 | } | 80 | } | ||
| 80 | p.unlockDispatch() | 81 | p.unlockDispatch() | ||
| 81 | p.reservation.Release() | 82 | p.reservation.Release() | ||
| 83 | + | d.log.Info("move committed", | |||
| 84 | + | "operation", p.operation, | |||
| 85 | + | "source_session", p.source.name, | |||
| 86 | + | "source_session_id", p.source.id, | |||
| 87 | + | "source_tab_id", p.sourceTab.stableID, | |||
| 88 | + | "destination_session", p.destination.name, | |||
| 89 | + | "destination_session_id", p.destination.id, | |||
| 90 | + | "source_retired", p.sourceEmpty, | |||
| 91 | + | ) | |||
| 82 | 92 | ||||
| 83 | // Repair attachment-local stable targets after shared topology publication | 93 | // Repair attachment-local stable targets after shared topology publication | ||
| 84 | // and after the move dispatch locks have been released. | 94 | // and after the move dispatch locks have been released. | ||
| @@ -114,6 +124,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 114 | if p.sourceEmpty { | 124 | if p.sourceEmpty { | ||
| 115 | retireEmptySessionAfterMove(d, p.source) | 125 | retireEmptySessionAfterMove(d, p.source) | ||
| 116 | } | 126 | } | ||
| 127 | + | if len(p.retiredAttachments) != 0 { | |||
| 128 | + | d.log.Info("move detaching source attachments", | |||
| 129 | + | "operation", p.operation, | |||
| 130 | + | "source_session", p.source.name, | |||
| 131 | + | "source_session_id", p.source.id, | |||
| 132 | + | "reason", "session-killed", | |||
| 133 | + | "attachments", len(p.retiredAttachments), | |||
| 134 | + | ) | |||
| 135 | + | } | |||
| 117 | for _, attachment := range p.retiredAttachments { | 136 | for _, attachment := range p.retiredAttachments { | ||
| 118 | d.unregisterPreview(attachment.ac) | 137 | d.unregisterPreview(attachment.ac) | ||
| 119 | attachment.ac.clearCaptureFrames() | 138 | attachment.ac.clearCaptureFrames() | ||
M internal/usecase/daemon/move_tab.go +21 -2
| @@ -42,8 +42,26 @@ type moveTabCommit struct { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 45 | - | defer func() { result = normalizeMoveRejection(result) }() | |
| 46 | - | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 45 | + | if d == nil { | |
| 46 | + | return errMovePaneInvalid | |
| 47 | + | } | |
| 48 | + | d.log.Info("tab move requested", | |
| 49 | + | "source_session_id", req.Source.ID, | |
| 50 | + | "source_tab_id", req.SourceTabID, | |
| 51 | + | "destination_session_id", req.Destination.ID, | |
| 52 | + | ) | |
| 53 | + | defer func() { | |
| 54 | + | result = normalizeMoveRejection(result) | |
| 55 | + | if result != nil { | |
| 56 | + | d.log.Warn("tab move rejected", | |
| 57 | + | "err", result, | |
| 58 | + | "source_session_id", req.Source.ID, | |
| 59 | + | "source_tab_id", req.SourceTabID, | |
| 60 | + | "destination_session_id", req.Destination.ID, | |
| 61 | + | ) | |
| 62 | + | } | |
| 63 | + | }() | |
| 64 | + | if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 47 | 65 | return errMovePaneInvalid | |
| 48 | 66 | } | |
| 49 | 67 | d.mu.Lock() | |
| @@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 134 | 152 | destinationTab: admission.tab, | |
| 135 | 153 | movedPane: firstMovePane(admission.panes), | |
| 136 | 154 | movedPanes: admission.panes, | |
| 155 | + | operation: "tab", | |
| 137 | 156 | sourceAttachments: admission.sourceAttachments, | |
| 138 | 157 | syncCleanup: commit.syncCleanup, | |
| 139 | 158 | frozenEffects: frozen, | |
| @@ -42,8 +42,26 @@ type moveTabCommit struct { | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | 44 | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | ||
| 45 | - | defer func() { result = normalizeMoveRejection(result) }() | 45 | + | if d == nil { |
| 46 | - | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | 46 | + | return errMovePaneInvalid |
| 47 | + | } | |||
| 48 | + | d.log.Info("tab move requested", | |||
| 49 | + | "source_session_id", req.Source.ID, | |||
| 50 | + | "source_tab_id", req.SourceTabID, | |||
| 51 | + | "destination_session_id", req.Destination.ID, | |||
| 52 | + | ) | |||
| 53 | + | defer func() { | |||
| 54 | + | result = normalizeMoveRejection(result) | |||
| 55 | + | if result != nil { | |||
| 56 | + | d.log.Warn("tab move rejected", | |||
| 57 | + | "err", result, | |||
| 58 | + | "source_session_id", req.Source.ID, | |||
| 59 | + | "source_tab_id", req.SourceTabID, | |||
| 60 | + | "destination_session_id", req.Destination.ID, | |||
| 61 | + | ) | |||
| 62 | + | } | |||
| 63 | + | }() | |||
| 64 | + | if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |||
| 47 | return errMovePaneInvalid | 65 | return errMovePaneInvalid | ||
| 48 | } | 66 | } | ||
| 49 | d.mu.Lock() | 67 | d.mu.Lock() | ||
| @@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||||
| 134 | destinationTab: admission.tab, | 152 | destinationTab: admission.tab, | ||
| 135 | movedPane: firstMovePane(admission.panes), | 153 | movedPane: firstMovePane(admission.panes), | ||
| 136 | movedPanes: admission.panes, | 154 | movedPanes: admission.panes, | ||
| 155 | + | operation: "tab", | |||
| 137 | sourceAttachments: admission.sourceAttachments, | 156 | sourceAttachments: admission.sourceAttachments, | ||
| 138 | syncCleanup: commit.syncCleanup, | 157 | syncCleanup: commit.syncCleanup, | ||
| 139 | frozenEffects: frozen, | 158 | frozenEffects: frozen, | ||