diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa6532..dc6b024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. +## [0.11.1] - 2026-07-16 + +### Fixed +- Opening a tab/pane lands on the live tail — terminal `
` no longer steals vertical scroll from the message list; stickiness also re-pins when content grows (04bf6fc)
+
 ## [0.11.0] - 2026-07-15
 
 ### Added
diff --git a/herdr-plugin.toml b/herdr-plugin.toml
index 9749c5c..982dad1 100644
--- a/herdr-plugin.toml
+++ b/herdr-plugin.toml
@@ -1,6 +1,6 @@
 id = "herdr.collie"
 name = "Collie"
-version = "0.11.0"
+version = "0.11.1"
 min_herdr_version = "0.7.0"
 description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale"
 platforms = ["linux", "macos"]
diff --git a/package.json b/package.json
index 9b297f2..3dca6f6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "collie",
-  "version": "0.11.0",
+  "version": "0.11.1",
   "private": true,
   "license": "MIT",
   "description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale",
diff --git a/web/package.json b/web/package.json
index 624b1a4..6d49f6e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
 {
   "name": "collie-web",
-  "version": "0.11.0",
+  "version": "0.11.1",
   "private": true,
   "license": "MIT",
   "type": "module",
diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx
index 3c7af99..459e50e 100644
--- a/web/src/components/agent-chat.tsx
+++ b/web/src/components/agent-chat.tsx
@@ -247,6 +247,13 @@ export function AgentChat({
     olderAnchor.current = null;
   }, [display]);
 
+  // Opening / switching into this pane must land on the live tail. Stickiness usually handles it,
+  // but the first flex layout + AnsiOutput paint can race; pin once after mount so a tab/pane open
+  // never strands you at the oldest scrollback.
+  useLayoutEffect(() => {
+    listRef.current?.scrollToBottom();
+  }, []);
+
   // After a successful send, snap the mirror back to the live tail so the reply's result is visible.
   const onSent = () => {
     setFollowing(true);
@@ -567,13 +574,15 @@ export function AgentChat({
 
         {/* Terminal mirror — tapping it focuses the composer so you can start typing right away
             (unless you're selecting text to copy, which the tap must not collapse). */}
-        
+ {/* min-w-0 only — do NOT set overflow-x-hidden here: that forces overflow-y to `auto` (CSS + quirk) and makes this wrapper a second vertical scroller competing with ChatMessageList. */} +
{display ? ( <> diff --git a/web/src/components/ansi-output.tsx b/web/src/components/ansi-output.tsx index 03131b7..f3abd99 100644 --- a/web/src/components/ansi-output.tsx +++ b/web/src/components/ansi-output.tsx @@ -73,7 +73,12 @@ function preClass(wrap: boolean, className?: string): string { "m-0 font-mono leading-[1.25] tracking-normal text-foreground [font-variant-ligatures:none]", wrap ? "whitespace-pre-wrap break-words" - : "min-w-0 w-full max-w-full whitespace-pre overflow-x-auto overscroll-x-contain [touch-action:pan-x_pan-y]", + : // Horizontal pan for wide TUI tables. `overflow-x-auto` forces `overflow-y` to compute to + // `auto` (CSS overflow quirk), and a flex item with non-visible overflow may shrink below its + // content height — the
 then becomes the vertical scroller and ChatMessageList's
+        // stickiness is a no-op (pane opens stuck at the oldest scrollback). `shrink-0` keeps the
+        // pre at content height so vertical scroll stays on the outer list; x-scroll still works.
+        "min-w-0 w-full max-w-full shrink-0 whitespace-pre overflow-x-auto overscroll-x-contain [touch-action:pan-x_pan-y]",
     className,
   );
 }
diff --git a/web/src/components/ui/chat/chat-message-list.tsx b/web/src/components/ui/chat/chat-message-list.tsx
index 043df76..4ebf401 100644
--- a/web/src/components/ui/chat/chat-message-list.tsx
+++ b/web/src/components/ui/chat/chat-message-list.tsx
@@ -42,11 +42,13 @@ const ChatMessageList = React.forwardRef
+        {/* Block scrollport (not flex-col): flex children with overflow-x-auto can shrink and steal
+            vertical scrolling from this container — see ansi-output preClass. */}
         
o.cb !== this.cb); + } } function setMetrics( @@ -26,9 +34,37 @@ function setMetrics( Object.defineProperty(el, "scrollTop", { value: m.scrollTop, configurable: true, writable: true }); } -function Harness() { - const { scrollRef, onScroll } = useAutoScroll({ dep: "constant" }); - return
; +function Harness({ dep = "constant" }: { dep?: unknown }) { + const { scrollRef, onScroll } = useAutoScroll({ dep }); + return ( +
+
body
+
+ ); +} + +function GrowingHarness() { + const [dep, setDep] = useState("a"); + const { scrollRef, onScroll, scrollToBottom } = useAutoScroll({ dep }); + return ( +
+
+
body
+
+ + +
+ ); +} + +function fireResize(el: Element) { + for (const o of observers.filter((x) => x.el === el)) { + o.cb([], {} as ResizeObserver); + } } describe("useAutoScroll — resize re-pin", () => { @@ -38,7 +74,7 @@ describe("useAutoScroll — resize re-pin", () => { if (!Element.prototype.scrollTo) Element.prototype.scrollTo = () => {}; }); beforeEach(() => { - roCallback = null; + observers = []; vi.stubGlobal("ResizeObserver", MockResizeObserver); }); afterEach(() => { @@ -55,7 +91,23 @@ describe("useAutoScroll — resize re-pin", () => { // Following by default (autoScroll = true), so a resize snaps the tail back into view — pinned // to scrollHeight, NOT a recomputed at-bottom (the shrink already pushed the tail off-screen). - act(() => roCallback?.([], {} as ResizeObserver)); + act(() => fireResize(el)); + + expect(scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" }); + }); + + it("re-pins when CONTENT grows while following (pane open / AnsiOutput layout)", () => { + // Opening a pane paints the scroll container at its final flex height first; the terminal + // mirror's content then grows inside it. That does NOT resize the container — only the child — + // so stickiness must observe content too, or the view stays stuck at the top of scrollback. + const { getByTestId } = render(); + const el = getByTestId("scroll"); + const content = getByTestId("content"); + setMetrics(el, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 }); + const scrollTo = vi.fn(); + el.scrollTo = scrollTo as unknown as HTMLElement["scrollTo"]; + + act(() => fireResize(content)); expect(scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" }); }); @@ -69,11 +121,23 @@ describe("useAutoScroll — resize re-pin", () => { const scrollTo = vi.fn(); el.scrollTo = scrollTo as unknown as HTMLElement["scrollTo"]; - act(() => roCallback?.([], {} as ResizeObserver)); + act(() => fireResize(el)); expect(scrollTo).not.toHaveBeenCalled(); }); + it("pins to the bottom on mount / when dep changes while following", () => { + const { getByTestId } = render(); + const el = getByTestId("scroll"); + setMetrics(el, { scrollHeight: 800, clientHeight: 200, scrollTop: 0 }); + const scrollTo = vi.fn(); + el.scrollTo = scrollTo as unknown as HTMLElement["scrollTo"]; + + act(() => fireEvent.click(getByTestId("grow-dep"))); + + expect(scrollTo).toHaveBeenCalledWith({ top: 800, behavior: "auto" }); + }); + it("no-ops without a ResizeObserver (jsdom / older browsers)", () => { vi.stubGlobal("ResizeObserver", undefined); // Mounting must not throw when the observer is absent — the effect bails on the typeof guard. diff --git a/web/src/hooks/use-auto-scroll.ts b/web/src/hooks/use-auto-scroll.ts index 7e52e2a..7bfa9ff 100644 --- a/web/src/hooks/use-auto-scroll.ts +++ b/web/src/hooks/use-auto-scroll.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; interface UseAutoScrollOptions { /** Px distance from the bottom still considered "at bottom". */ @@ -24,11 +24,17 @@ export function useAutoScroll( [offset], ); + const pinToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el || !autoScroll.current) return; + el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); + }, []); + const scrollToBottom = useCallback(() => { const el = scrollRef.current; if (!el) return; - el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); autoScroll.current = true; + el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); setIsAtBottom(true); onAtBottomChange?.(true); }, [onAtBottomChange]); @@ -42,30 +48,48 @@ export function useAutoScroll( onAtBottomChange?.(bottom); }, [atBottom, onAtBottomChange]); - // Re-pin to bottom when new content arrives, unless the user has scrolled away. - useEffect(() => { - const el = scrollRef.current; - if (!el || !autoScroll.current) return; - requestAnimationFrame(() => { - el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); - }); - }, [dep]); + // Re-pin before paint when new content arrives — opening a pane / switching tabs must land on + // the live tail without a flash of the oldest scrollback. Yields if the user has scrolled away. + useLayoutEffect(() => { + pinToBottom(); + }, [dep, pinToBottom]); - // Re-pin when the container itself RESIZES while we're following — a shrinking viewport (the keys - // dock opening above the composer, or the on-screen keyboard) pushes the tail below the fold, so - // stickiness must snap it back. Keyed on the captured `autoScroll` intent, NOT a recomputed - // at-bottom (the shrink already moved the view off bottom); a scrolled-up user is left in place. + // Re-pin when the container OR its content resizes while we're following. + // - Container: a shrinking viewport (keys dock, on-screen keyboard) pushes the tail below the fold. + // - Content: opening a pane paints the flex-sized scroller first; AnsiOutput then grows inside it. + // That does not change the container's border box, so observing only `el` leaves you stuck at the + // top of scrollback. Keyed on the captured `autoScroll` intent, NOT a recomputed at-bottom (a + // shrink already moved the view off bottom); a scrolled-up user is left in place. // Guarded for jsdom, which has no ResizeObserver. useEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { - if (!autoScroll.current) return; - el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); + pinToBottom(); }); ro.observe(el); - return () => ro.disconnect(); - }, []); + for (const child of Array.from(el.children)) { + ro.observe(child); + } + + // React replaces/grows children across polls and pane opens — keep observing new nodes and + // re-pin when the child list changes while following. + const mo = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof Element) ro.observe(node); + } + } + pinToBottom(); + }); + mo.observe(el, { childList: true }); + + return () => { + ro.disconnect(); + mo.disconnect(); + }; + }, [pinToBottom]); return { scrollRef, isAtBottom, scrollToBottom, onScroll }; }