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 };
}