Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` no longer steals vertical scroll from the message list; stickiness also re-pins when content grows (04bf6fc)

## [0.11.0] - 2026-07-15

### Added
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie-web",
"version": "0.11.0",
"version": "0.11.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
13 changes: 11 additions & 2 deletions web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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). */}
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden" onClick={focusFromMirror}>
{/* 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. */}
<div className="min-h-0 min-w-0 flex-1" onClick={focusFromMirror}>
<ChatMessageList
ref={listRef}
dep={display}
onAtBottomChange={setFollowing}
hasNew={hasNew}
className="gap-0 px-2 py-3"
className="px-2 py-3"
>
{display ? (
<>
Expand Down
7 changes: 6 additions & 1 deletion web/src/components/ansi-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre> 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,
);
}
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/ui/chat/chat-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ const ChatMessageList = React.forwardRef<ChatMessageListHandle, ChatMessageListP

return (
<div className="relative h-full min-w-0 w-full">
{/* Block scrollport (not flex-col): flex children with overflow-x-auto can shrink and steal
vertical scrolling from this container — see ansi-output preClass. */}
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
"flex h-full min-w-0 w-full flex-col gap-4 overflow-y-auto overflow-x-hidden px-3 py-4",
"h-full min-w-0 w-full overflow-y-auto overflow-x-hidden px-3 py-4",
className,
)}
{...props}
Expand Down
86 changes: 75 additions & 11 deletions web/src/hooks/use-auto-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { act, fireEvent, render } from "@testing-library/react";
import { useState } from "react";

import { useAutoScroll } from "./use-auto-scroll";

// use-auto-scroll's stickiness needs a real DOM ref (scrollRef attached to an element) plus a
// ResizeObserver — jsdom has neither the layout nor the observer, so we mount a tiny harness, pin
// the element's scroll metrics by hand, and drive a mocked ResizeObserver's callback ourselves.

// The most-recently-constructed observer's callback — fire it to simulate a container resize.
let roCallback: ResizeObserverCallback | null = null;
type Observed = { el: Element; cb: ResizeObserverCallback };

// Every constructed observer, so tests can fire container and/or content observations.
let observers: Observed[] = [];
class MockResizeObserver {
private cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
roCallback = cb;
this.cb = cb;
}
observe(el: Element) {
observers.push({ el, cb: this.cb });
}
observe() {}
unobserve() {}
disconnect() {}
disconnect() {
observers = observers.filter((o) => o.cb !== this.cb);
}
}

function setMetrics(
Expand All @@ -26,9 +34,37 @@ function setMetrics(
Object.defineProperty(el, "scrollTop", { value: m.scrollTop, configurable: true, writable: true });
}

function Harness() {
const { scrollRef, onScroll } = useAutoScroll<HTMLDivElement>({ dep: "constant" });
return <div ref={scrollRef} onScroll={onScroll} data-testid="scroll" />;
function Harness({ dep = "constant" }: { dep?: unknown }) {
const { scrollRef, onScroll } = useAutoScroll<HTMLDivElement>({ dep });
return (
<div ref={scrollRef} onScroll={onScroll} data-testid="scroll">
<div data-testid="content">body</div>
</div>
);
}

function GrowingHarness() {
const [dep, setDep] = useState("a");
const { scrollRef, onScroll, scrollToBottom } = useAutoScroll<HTMLDivElement>({ dep });
return (
<div>
<div ref={scrollRef} onScroll={onScroll} data-testid="scroll">
<div data-testid="content">body</div>
</div>
<button type="button" onClick={() => setDep("b")} data-testid="grow-dep">
grow dep
</button>
<button type="button" onClick={() => scrollToBottom()} data-testid="jump">
jump
</button>
</div>
);
}

function fireResize(el: Element) {
for (const o of observers.filter((x) => x.el === el)) {
o.cb([], {} as ResizeObserver);
}
}

describe("useAutoScroll — resize re-pin", () => {
Expand All @@ -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(() => {
Expand All @@ -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(<Harness />);
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" });
});
Expand All @@ -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(<GrowingHarness />);
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.
Expand Down
60 changes: 42 additions & 18 deletions web/src/hooks/use-auto-scroll.ts
Original file line number Diff line number Diff line change
@@ -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". */
Expand All @@ -24,11 +24,17 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
[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]);
Expand All @@ -42,30 +48,48 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
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 };
}
Loading