instar 0.28.58 → 0.28.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3221,6 +3221,7 @@
3221
3221
 
3222
3222
  activeSession = tmuxSession;
3223
3223
  userIsFollowing = true; // Reset scroll tracking on session switch
3224
+ hideResumeButton(); // Carry-over button from prior session would be misleading
3224
3225
  historyLinesLoaded = 2000; // Reset history state for new session
3225
3226
  historyLoading = false;
3226
3227
  historyExhausted = false;
@@ -3334,15 +3335,20 @@
3334
3335
  if (atBottom && !userIsFollowing) {
3335
3336
  // User scrolled back to bottom — resume following and apply pending output
3336
3337
  userIsFollowing = true;
3338
+ hideResumeButton();
3337
3339
  if (pendingOutputData) {
3338
3340
  const data = pendingOutputData;
3339
3341
  pendingOutputData = null;
3340
3342
  term.clear();
3341
- term.write(data);
3342
- term.scrollToBottom();
3343
+ // term.write is async — scroll after write completes so the buffer is populated.
3344
+ term.write(data, () => { term.scrollToBottom(); });
3343
3345
  }
3344
- } else if (!atBottom) {
3346
+ } else if (!atBottom && userIsFollowing) {
3347
+ // User scrolled up — pause follow and surface the resume button
3348
+ // even if no new output is pending. The button is the always-available
3349
+ // "take me back to live" control.
3345
3350
  userIsFollowing = false;
3351
+ showResumeButton();
3346
3352
  }
3347
3353
 
3348
3354
  // Infinite scroll: load more history when scrolled near the top
@@ -3370,12 +3376,13 @@
3370
3376
  const atBottom = buf.baseY === 0 || (buf.baseY - buf.viewportY) <= 5;
3371
3377
  if (atBottom) {
3372
3378
  userIsFollowing = true;
3379
+ hideResumeButton();
3373
3380
  if (pendingOutputData) {
3374
3381
  const data = pendingOutputData;
3375
3382
  pendingOutputData = null;
3376
3383
  term.clear();
3377
- term.write(data);
3378
- term.scrollToBottom();
3384
+ // term.write is async — scroll after write completes so the buffer is populated.
3385
+ term.write(data, () => { term.scrollToBottom(); });
3379
3386
  }
3380
3387
  }
3381
3388
  }
@@ -3449,23 +3456,20 @@
3449
3456
  btn.style.cssText = 'position:absolute;bottom:12px;left:50%;transform:translateX(-50%);z-index:10;background:var(--accent);color:#000;border:none;padding:6px 16px;border-radius:4px;font-size:12px;cursor:pointer;font-weight:600;opacity:0.95;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
3450
3457
  btn.onclick = () => {
3451
3458
  userIsFollowing = true;
3459
+ const snapToBottom = () => {
3460
+ term.scrollToBottom();
3461
+ const container = document.getElementById('terminalContainer');
3462
+ if (container) container.scrollIntoView({ block: 'end', behavior: 'instant' });
3463
+ };
3452
3464
  if (pendingOutputData) {
3453
3465
  const data = pendingOutputData;
3454
3466
  pendingOutputData = null;
3455
3467
  term.clear();
3456
- term.write(data);
3457
- // Delay scrollToBottom until after xterm renders the written data
3458
- requestAnimationFrame(() => {
3459
- term.scrollToBottom();
3460
- // Also scroll the browser viewport to show the terminal bottom
3461
- const container = document.getElementById('terminalContainer');
3462
- if (container) container.scrollIntoView({ block: 'end', behavior: 'instant' });
3463
- });
3468
+ // term.write is async — use its completion callback so scroll runs
3469
+ // after the buffer is populated, not against a stale viewport.
3470
+ term.write(data, snapToBottom);
3464
3471
  } else {
3465
- // No pending data — just snap to bottom of existing content
3466
- term.scrollToBottom();
3467
- const container = document.getElementById('terminalContainer');
3468
- if (container) container.scrollIntoView({ block: 'end', behavior: 'instant' });
3472
+ snapToBottom();
3469
3473
  }
3470
3474
  hideResumeButton();
3471
3475
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.28.58",
3
+ "version": "0.28.59",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-04-19T02:15:10.872Z",
5
- "instarVersion": "0.28.58",
4
+ "generatedAt": "2026-04-19T02:56:02.572Z",
5
+ "instarVersion": "0.28.59",
6
6
  "entryCount": 186,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,85 @@
1
+ # Upgrade Guide — Dashboard "Resume live output" reliability + always-visible control
2
+
3
+ ## What Changed
4
+
5
+ Fixed two long-standing rough edges in the dashboard's terminal view for the
6
+ "▼ Resume live output" control.
7
+
8
+ ### Scroll-to-bottom now actually lands at the bottom
9
+
10
+ Clicking "▼ Resume live output" after scrolling up in a session view used to
11
+ sometimes leave the viewport mid-history instead of snapping to the tail of
12
+ live output — defeating the purpose of the button. The same stale-scroll
13
+ happened on the two sibling auto-resume paths that fire when the user
14
+ scrolls back to the bottom or wheels down into it.
15
+
16
+ Root cause: `term.write(data)` in xterm is asynchronous. The old code called
17
+ `term.scrollToBottom()` immediately after the write, or inside a
18
+ `requestAnimationFrame` — neither guarantees the buffer has been populated,
19
+ so the scroll operated on stale viewport state and landed above the new
20
+ output.
21
+
22
+ Fix: all three resume paths now use xterm's write-completion callback —
23
+ `term.write(data, () => term.scrollToBottom())`. The scroll runs after the
24
+ buffer is populated, so the viewport lands at the new bottom.
25
+
26
+ ### The button is always visible when paused
27
+
28
+ Previously the "▼ Resume live output" button only appeared when new output
29
+ happened to arrive while the user was scrolled up. In idle sessions —
30
+ nothing streaming, user just scrolled up to read history — the button never
31
+ surfaced, and there was no UI control to jump back to live.
32
+
33
+ Fix: button visibility now mirrors `!userIsFollowing` from every code path
34
+ that flips the flag. Scroll up in a session view (idle or streaming) and the
35
+ button appears. Scroll back to the bottom, click the button, wheel down into
36
+ the tail, or switch sessions, and the button hides.
37
+
38
+ ## What to Tell Your User
39
+
40
+ The "Resume live output" button in your dashboard's session view now works
41
+ the way you'd expect: it shows up any time you've scrolled up (even in an
42
+ idle session where nothing is streaming), and clicking it reliably drops
43
+ you at the bottom of the live output instead of leaving you mid-history.
44
+
45
+ ## Summary of New Capabilities
46
+
47
+ | Capability | How to Use |
48
+ |-----------|-----------|
49
+ | Reliable auto-follow resume in the dashboard terminal | Click "▼ Resume live output" after scrolling up in any session view; viewport snaps to the bottom |
50
+ | Button always available while paused | Scroll up in any session — the button surfaces automatically; scroll back down or click it to resume |
51
+
52
+ ## Evidence
53
+
54
+ The fix was reproduced from Justin's report (topic 6309 on echo agent): the
55
+ button left him mid-history, and in idle sessions there was no button at
56
+ all.
57
+
58
+ Regression test (`tests/unit/dashboard-resumeLive.test.ts`, 10 tests) locks
59
+ in both invariants at the HTML-inspection layer:
60
+
61
+ - Scroll-after-write: all three resume paths use the xterm write callback;
62
+ the old `term.write(data); requestAnimationFrame(() => term.scrollToBottom())`
63
+ shape is explicitly banned.
64
+ - Button visibility: `term.onScroll` shows the button on scroll-up and
65
+ hides it on scroll-to-bottom; the wheel-down resume path hides the
66
+ button; session-switch reset hides any carry-over button.
67
+
68
+ Full dashboard test suite green across `dashboard-*.test.ts`.
69
+
70
+ Side-effects review:
71
+ `upgrades/side-effects/dashboard-resume-live-scroll.md` — concludes no
72
+ decision-point touched (pure UI timing + visibility fix), no over/under-block
73
+ risk, trivial rollback (git revert).
74
+
75
+ ## Deployment Notes
76
+
77
+ No operator action required on update. The dashboard is static HTML served
78
+ from the installed instar package — clients pick up the fix on their next
79
+ page load after `instar` updates to 0.28.59.
80
+
81
+ ## Rollback
82
+
83
+ Downgrade reverts the three resume paths to the prior (racy) code and
84
+ returns the button to its contingent-on-new-output visibility. No schema
85
+ changes, no state-file changes, no API changes.
@@ -0,0 +1,132 @@
1
+ # Side-Effects Review — Dashboard "Resume live output" reliability + always-visible while paused
2
+
3
+ **Version / slug:** `dashboard-resume-live-scroll`
4
+ **Date:** `2026-04-18`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required — pure UI timing fix, no gate/sentinel/watchdog/session-lifecycle surface`
7
+
8
+ ## Summary of the change
9
+
10
+ The dashboard's "Resume live output" button (and the two sibling resume paths that
11
+ fire on scroll-to-bottom and wheel-down) did not reliably snap the xterm viewport
12
+ to the bottom after being clicked. The user still landed mid-history and had to
13
+ scroll manually to see live output — defeating the purpose of the button.
14
+
15
+ Root cause: `term.write(data)` is asynchronous — xterm parses and renders the
16
+ write on a microtask. The old code called `term.scrollToBottom()` either
17
+ immediately after the write or inside a `requestAnimationFrame`. Neither
18
+ guarantees the buffer has been populated, so `scrollToBottom()` snapped against
19
+ a stale `baseY` and landed above the new output.
20
+
21
+ Fix: use xterm's write-completion callback — `term.write(data, () => { term.scrollToBottom(); })`.
22
+ The callback fires after the write is flushed, so the scroll operates on the
23
+ final buffer state.
24
+
25
+ ## Secondary change — button visibility mirrors follow state
26
+
27
+ The prior behavior only showed the "▼ Resume live output" button when new
28
+ output happened to arrive while the user was scrolled up — via `showResumeButton()`
29
+ inside `renderTerminalOutput`'s not-following branch. If the user scrolled up in
30
+ an idle session (no new output arriving), the button never appeared, and there
31
+ was no UI control to jump back to live.
32
+
33
+ Fix: `term.onScroll` now surfaces the button the moment the user scrolls up
34
+ (`!atBottom && userIsFollowing` → flip false + `showResumeButton()`), and hides
35
+ it the moment they return to the bottom by any means (`atBottom && !userIsFollowing`
36
+ → flip true + `hideResumeButton()`). The wheel-down resume path hides the button
37
+ for the same reason. Session-switch reset calls `hideResumeButton()` so a
38
+ carry-over button from a prior session doesn't mislead.
39
+
40
+ The button is now the always-available "take me back to live" control, not a
41
+ contingent reaction to incoming output.
42
+
43
+ Files touched:
44
+
45
+ - `dashboard/index.html` — two overlapping changes:
46
+ - Three resume paths use xterm's write-completion callback:
47
+ 1. The button `onclick` handler.
48
+ 2. The `term.onScroll` handler's auto-resume branch.
49
+ 3. The `xtermViewport` wheel handler's auto-resume branch.
50
+ The button handler retains `container.scrollIntoView({block:'end'})` in a
51
+ shared `snapToBottom` closure so both the pending-data and no-pending-data
52
+ branches do the same thing.
53
+ - Button visibility now mirrors `!userIsFollowing` from every path that
54
+ flips the flag: scroll-up in `onScroll`, scroll-to-bottom in `onScroll`,
55
+ wheel-down resume, and session-switch reset.
56
+ - `tests/unit/dashboard-resumeLive.test.ts` — new. HTML-inspection regression
57
+ test, consistent with the existing `dashboard-*.test.ts` pattern. Ten tests,
58
+ split across two describe blocks: (a) scroll-after-write invariants — locks
59
+ in the callback form on all three paths and bans the old
60
+ `term.write(data); requestAnimationFrame(() => term.scrollToBottom())` shape;
61
+ (b) button-visibility invariants — asserts show on scroll-up, hide on
62
+ scroll-to-bottom / wheel-resume / session switch.
63
+
64
+ ## 1. Over-block
65
+
66
+ N/A — no block/allow decision exists in this change. The only behavior altered
67
+ is the timing of a DOM scroll call relative to an xterm write.
68
+
69
+ ## 2. Under-block
70
+
71
+ N/A — see Over-block.
72
+
73
+ ## 3. Level-of-abstraction fit
74
+
75
+ The fix lives at the exact layer where the bug lives: dashboard JavaScript
76
+ interacting with xterm.js. xterm's documented API for scheduling work after
77
+ a write is the write callback; using `requestAnimationFrame` was a workaround
78
+ that guessed at timing instead of using the library's native hook. The fix
79
+ moves us from guessing to the library-native pattern.
80
+
81
+ There is no higher-level authority that should own terminal-viewport scrolling
82
+ — this is a leaf UI behavior. There is no lower layer to delegate to — xterm
83
+ itself is the layer.
84
+
85
+ ## 4. Signal-vs-authority compliance
86
+
87
+ No decision point touched. This is not a gate, filter, sentinel, watchdog, or
88
+ dispatcher. No brittle logic holds blocking authority. The principle does not
89
+ apply. Documented in Phase 1.
90
+
91
+ ## 5. Interactions
92
+
93
+ - **`renderTerminalOutput` (the steady-state write path):** Still uses the
94
+ bare `term.write(data); term.scrollToBottom();` form. That path runs only
95
+ when `userIsFollowing === true` — xterm's default behavior is to auto-track
96
+ the bottom when the viewport is already there, so the follow-up
97
+ `scrollToBottom()` is essentially redundant and harmless. Not touched.
98
+ - **`term.onScroll` flag-flipping:** The scroll event fires during
99
+ `term.write()` as the buffer grows. If the write callback runs after
100
+ `scrollToBottom()`, the onScroll handler sees `atBottom === true` and
101
+ keeps `userIsFollowing === true`. No race with flag-flipping introduced.
102
+ - **Concurrent WS output:** If a new frame arrives via WebSocket during the
103
+ click handler, it calls `renderTerminalOutput` with `userIsFollowing === true`
104
+ (we just set it). That path does its own `term.clear()` + `term.write` +
105
+ `scrollToBottom()`, which will overwrite whatever our click handler was
106
+ mid-flight. Net effect: user ends up at bottom either way. No deadlock, no
107
+ torn state.
108
+ - **Infinite-scroll history loader:** Unaffected — uses `buf.viewportY <= 10`,
109
+ which is a separate concern from the bottom-snap logic.
110
+
111
+ ## 6. External surfaces
112
+
113
+ None. This is client-side JavaScript in the dashboard HTML, served to the
114
+ user's browser. No other agents, no other users, no other systems observe the
115
+ change. No server API touched. No persisted state touched.
116
+
117
+ ## 7. Rollback cost
118
+
119
+ Trivial: `git revert <commit>`. No data migration, no state repair, no release
120
+ coordination. Dashboard is static HTML served by the instar server; a revert
121
+ commit + server restart puts the old code back in front of the user
122
+ immediately.
123
+
124
+ ## Verification
125
+
126
+ - HTML-inspection regression test passing (`tests/unit/dashboard-resumeLive.test.ts`, 6 tests).
127
+ - Full dashboard test suite passing (`dashboard-*.test.ts`, 20 tests total).
128
+ - Manual browser verification: deferred to user acceptance — reproducing the
129
+ bug end-to-end requires a live session with pending output and a user
130
+ scrolled up, which is Justin's original reporting path. The mechanical fix
131
+ is narrow enough (swap `term.write(data); scroll` → `term.write(data, scroll)`)
132
+ that the HTML regression is the load-bearing guarantee.