The pane waited 150 ms on hover and 60 ms on a keyboard step before following the sidebar highlight, so every preview paid for the fast walk it might have been. Replace both with a leading-edge 120 ms gap: the first move shows at once, a burst is coalesced to its start and its end. The row's own hover no longer eases either — `background` was on a 120 ms transition, which is what read as lag on the row itself. The sidebar also rebuilt its whole tree on every `SessionUpdated`, a couple of times a minute per live session, dropping `:hover` from the row under the pointer. Refreshes that change nothing structural now patch the activity rail and meta line into the standing rows instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { renderSidebar } from "./catalog.js";
|
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
|
import type { Session } from "./types.js";
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: { getItem: () => null, setItem: () => {} },
|
|
});
|
|
window.fetch = () => Promise.reject(new Error("offline"));
|
|
});
|
|
|
|
const ID = "11111111-2222-3333-4444-555555555555";
|
|
|
|
function session(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: ID,
|
|
cwd: "/home/naps/tea/maestro",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-08-24T10:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** A store whose updates actually reach subscribers, unlike the one-shot
|
|
* harness in catalog.test.ts. */
|
|
function liveStore(initial: Session[]) {
|
|
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
|
const listeners = new Set<CatalogListener>();
|
|
const store = {
|
|
snapshot: () => state,
|
|
subscribe: (fn: CatalogListener) => {
|
|
listeners.add(fn);
|
|
fn(state);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
} as unknown as CatalogStore;
|
|
const push = (sessions: Session[]) => {
|
|
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
|
for (const fn of listeners) fn(state);
|
|
};
|
|
return { store, push };
|
|
}
|
|
|
|
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
|
|
|
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
|
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
|
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
|
const { el } = renderSidebar(store, {});
|
|
const before = rowOf(el);
|
|
|
|
push([session({ activity: "AwaitingInput" })]);
|
|
|
|
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
|
// the row's transitions, which is the flicker this avoids.
|
|
expect(rowOf(el)).toBe(before);
|
|
// …and the card still tells the truth about the session.
|
|
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
|
"awaiting-input",
|
|
);
|
|
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
|
"awaiting-input",
|
|
);
|
|
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
|
});
|
|
|
|
it("rebuilds when the row set changes", () => {
|
|
const { store, push } = liveStore([session()]);
|
|
const { el } = renderSidebar(store, {});
|
|
const before = rowOf(el);
|
|
|
|
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
|
|
|
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
|
expect(rowOf(el)).not.toBe(before);
|
|
});
|
|
|
|
it("rebuilds when a session dies", () => {
|
|
const { store, push } = liveStore([session()]);
|
|
const { el } = renderSidebar(store, {});
|
|
const before = rowOf(el);
|
|
|
|
push([session({ status: "Dead" })]);
|
|
|
|
expect(rowOf(el)).not.toBe(before);
|
|
expect(rowOf(el).dataset.status).toBe("dead");
|
|
});
|
|
|
|
it("rebuilds when a session is renamed", () => {
|
|
const { store, push } = liveStore([session()]);
|
|
const { el } = renderSidebar(store, {});
|
|
const before = rowOf(el);
|
|
|
|
push([session({ metadata: { name: "harmony" } })]);
|
|
|
|
expect(rowOf(el)).not.toBe(before);
|
|
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
|
});
|
|
});
|