Skip to content

Commit d24a80b

Browse files
fix(sidebar): harden width sync invariants
- preserve user-driven width through fast session and pane handoff - make adjustment state explicit for global sidebar enforcement - document sidebar behavior invariants and reference them in AGENTS
1 parent 998b683 commit d24a80b

5 files changed

Lines changed: 1022 additions & 149 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ interface AgentWatcher {
9898
- **TDD**: Red-green-refactor, vertical slices, one test at a time. Tests verify behavior through public interfaces.
9999
- **Sync mux calls**: MuxProvider methods are synchronous. Don't make them async.
100100
- **Preserve optimizations**: Batched tmux calls, 5s git cache with HEAD watchers, lightweight focus-only broadcasts.
101+
- **Sidebar resize work**: Before changing sidebar spawning, width sync, tmux resize handling, or `sidebar-coordinator`, read `docs/explanation/sidebar-behavior.md` and preserve those invariants unless you update the doc in the same change.
101102
- **Built-in watchers in runtime**: Amp, Claude Code, Codex, and OpenCode have built-in watchers in `packages/runtime/src/agents/watchers/`. Community agents use the `AgentWatcher` plugin interface.
102103
- **OpenTUI Solid**: JSX needs `bunfig.toml` preload and `jsxImportSource: "@opentui/solid"` in tsconfig. Build needs `solidPlugin`.
103104
- **Never call `process.exit()` directly in TUI**: Use `renderer.destroy()`.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Sidebar Behavior Invariants
2+
3+
This document captures the sidebar behaviors that were learned the hard way while making tmux sidebar resizing feel native, stable, and predictable.
4+
5+
If you change sidebar spawning, width sync, tmux hook handling, focus/session switching, or the `sidebar-coordinator` state machine, read this first.
6+
7+
## The Product Contract
8+
9+
The sidebar should behave like a real sidebar, not like an ordinary tmux pane.
10+
11+
That means:
12+
13+
- the sidebar width stays fixed until the user explicitly drags it
14+
- the width propagates globally across every sidebar pane in every managed session/window
15+
- full terminal resizes do not redefine the saved width
16+
- session switching does not cause the sidebar to jump, breathe, or re-proportion itself
17+
- background windows should already be correct before the user lands in them
18+
- the UI should clearly show `warming up…` while sidebars are still spawning and `adjusting…` while width normalization is still in flight
19+
20+
## Width Authority
21+
22+
Only true user intent should persist a new width.
23+
24+
The accepted rule set is:
25+
26+
- only the foreground sidebar in the active session can author a new width
27+
- background sidebars never get to redefine global width
28+
- a user drag may continue for a short tail window even if focus moves immediately after the drag starts
29+
- programmatic tmux resizes, session switches, and terminal resizes must be treated as echoes unless we have evidence of real user drag intent
30+
- when a switch happens too quickly for the TUI to emit `report-width`, the server must opportunistically adopt the source window's actual sidebar pane width before switching
31+
32+
In practice, width authority is split into these cases:
33+
34+
- `user-drag`: a real user-driven sidebar resize
35+
- `client-resize-sync`: the server correcting widths after a whole terminal/client resize
36+
- `programmatic-adjust`: the server normalizing widths during ensure/switch/fan-out paths
37+
- `none`: no resize authority is active
38+
39+
## Global Propagation Rules
40+
41+
When a width change is accepted:
42+
43+
- the persisted width changes once
44+
- the server fans that width out to every other sidebar pane
45+
- the source pane/window should not be fought by the fan-out pass
46+
- rapid switching must not cut propagation short
47+
48+
This was a real bug: a drag could be accepted in one pane, then a fast switch would happen before later reports arrived, and the destination session would snap everything back to the old stored width. The current rule is to capture the source sidebar pane width during switch handoff so explicit user resizing is not lost.
49+
50+
## Terminal Resize Rules
51+
52+
External terminal resizing is not the same thing as sidebar resizing.
53+
54+
Expected behavior:
55+
56+
- moving between monitor sizes or resizing Ghostty/iTerm should not change the saved sidebar width
57+
- the foreground window should be corrected quickly so the sidebar does not visually breathe
58+
- background windows can catch up with a staggered sync pass after a short settle delay
59+
- transient half-window widths reported during client resizes must never become the persisted width
60+
61+
The server therefore needs both:
62+
63+
- a suppression window to ignore server-induced resize echoes
64+
- a client-resize guard window so transient widths during full terminal resize do not get mistaken for user drag
65+
66+
## Session Switching Rules
67+
68+
Session switching should feel boring.
69+
70+
That means:
71+
72+
- the destination session/window should already have a sidebar at the current global width
73+
- switching must not trigger visible layout jumps
74+
- switching must not reset the width to an older value
75+
- if the user resized immediately before switching, the just-resized width must survive the switch
76+
77+
One specific regression we already paid for: forcing `resize-window` during the session-switch path caused visible layout jumps. The fix was to stop doing that in the switch path and instead use targeted width enforcement plus background pre-layout where appropriate.
78+
79+
## Warmup And Adjusting Semantics
80+
81+
There are two user-visible initializing states and they mean different things.
82+
83+
`warming up…` means:
84+
85+
- sidebars are being spawned/restored across windows
86+
- the system is still converging on presence, not width
87+
88+
`adjusting…` means:
89+
90+
- width normalization is still in flight across windows/sessions
91+
- this includes whole-client resize sync, accepted drag propagation, and server-driven cross-window enforcement
92+
93+
Important nuance:
94+
95+
- if warmup and a global adjustment overlap, the UI should prefer `adjusting…`
96+
- warmup must not get stranded forever because a resize sync canceled the only completion timer
97+
98+
## tmux-Specific Invariants
99+
100+
tmux has several behaviors that look reasonable until they break the sidebar.
101+
102+
These are non-negotiable:
103+
104+
- ignore control-mode clients with empty `client_tty` when inferring current session or foreground client
105+
- keep tmux windows in `window-size latest`; do not leave them in manual mode after `resize-window`
106+
- do not use `after-resize-pane` as width authority for this feature; it was tried before and led to sync difficulty and resize loops
107+
- do not refocus the main pane immediately after sidebar spawn/restore; let the TUI refocus after capability detection settles so escape sequences do not leak into the main pane
108+
- invalidate cached sidebar pane listings before logic that depends on just-spawned or just-hidden panes
109+
110+
## Regressions We Already Paid For
111+
112+
These are the big historical failure modes worth remembering.
113+
114+
### 1. Jamming tmux's resizer
115+
116+
What happened:
117+
118+
- the server tried to do too much synchronous resize work during tmux resize storms
119+
- or it got stuck in echo/enforcement loops where programmatic resizes caused more programmatic resizes
120+
121+
What fixed it:
122+
123+
- deferred client-resize sync after a short settle window
124+
- fast staggered fan-out for background windows
125+
- ignore-only suppression rather than recursive re-enforcement loops
126+
- explicit re-entrancy guards around enforcement passes
127+
128+
### 2. Treating external width changes as user intent
129+
130+
What happened:
131+
132+
- full terminal resizes and other layout churn produced `report-width` values that looked like drags
133+
- the saved width changed even though the user never dragged the divider
134+
135+
What fixed it:
136+
137+
- only the foreground active sidebar can author width
138+
- client-resize guard windows reject transient reports during full terminal resize
139+
- the state machine models causality so programmatic adjustments and user drags are not conflated
140+
141+
### 3. Switching quickly stopped propagation
142+
143+
What happened:
144+
145+
- the initial width report could be accepted
146+
- later reports in the same drag were suppressed or never arrived before a switch
147+
- the destination session then re-enforced the older persisted width
148+
149+
What fixed it:
150+
151+
- longer drag settle windows so drag authority survives realistic report timing
152+
- drag-tail acceptance for the originating pane even after focus changes
153+
- source-window width adoption during switch handoff so the latest real pane width is not lost if the TUI report races with the switch
154+
155+
### 4. Background windows were stale
156+
157+
What happened:
158+
159+
- only the active session/window got corrected promptly
160+
- switching into a background window revealed a stale sidebar width flash
161+
162+
What fixed it:
163+
164+
- pre-layout plus staggered background correction
165+
- global fan-out that includes sibling windows, not just other sessions
166+
167+
### 5. Manual window-size mode poisoned layouts
168+
169+
What happened:
170+
171+
- `resize-window` left tmux windows in `window-size manual`
172+
- later terminal behavior looked padded or broken
173+
174+
What fixed it:
175+
176+
- always restore `window-size latest` after forced window resizes
177+
178+
## Rejected Approaches
179+
180+
These are not theoretical. They were tried and caused problems.
181+
182+
- using `after-resize-pane` as the main width-authority mechanism
183+
- forcing `resize-window` directly in the normal session-switch path
184+
- suppressing width reports so broadly that legitimate drag events got blocked
185+
- setting drag suppression in a way that made the server fight the user's live drag
186+
- treating every TUI as authoritative instead of only the current foreground one
187+
188+
## Performance Constraints
189+
190+
The sidebar should not make tmux feel heavy.
191+
192+
Keep these constraints in mind:
193+
194+
- stagger expensive cross-window work instead of doing it all inline in hooks
195+
- avoid repeated full `list-panes -a` scans inside the same resize cycle
196+
- batch where possible, cache briefly, and invalidate on real topology changes
197+
- prioritize the active window first, then let the rest catch up quickly in the background
198+
199+
## Change Checklist
200+
201+
Before shipping any sidebar behavior change, verify all of these.
202+
203+
- dragging the active sidebar changes width smoothly
204+
- switching sessions immediately after a drag preserves the new width
205+
- resizing the whole terminal does not redefine the persisted width
206+
- background windows land at the current width without visible proportional flash
207+
- `warming up…` clears once spawn/restore is complete
208+
- `adjusting…` appears reliably while global width correction is still happening
209+
- control-mode clients cannot steal foreground/current-session authority
210+
- tmux windows remain in `window-size latest`
211+
- no resize or enforcement loop appears in `/tmp/opensessions-debug.log`
212+
213+
## Files To Read Before Changing This Area
214+
215+
- `packages/runtime/src/server/sidebar-coordinator.ts`
216+
- `packages/runtime/src/server/index.ts`
217+
- `packages/mux/providers/tmux/src/provider.ts`
218+
- `packages/mux/tmux-sdk/src/index.ts`
219+
- `apps/tui/src/index.tsx`
220+
- `packages/runtime/test/sidebar-coordinator.test.ts`
221+
222+
If a future change violates this doc but seems necessary, update the doc in the same change and explain the new invariant explicitly.

0 commit comments

Comments
 (0)