diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/ui/app-scroll.test.ts
index 111b54de9..244d61c35 100644
--- a/ui/src/ui/app-scroll.test.ts
+++ b/ui/src/ui/app-scroll.test.ts
@@ -61,45 +61,39 @@ function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight
/* ------------------------------------------------------------------ */
describe("handleChatScroll", () => {
- it("sets chatUserNearBottom=true when within the 450px threshold", () => {
- const { host } = createScrollHost({});
- // distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
- const event = createScrollEvent(2000, 1600, 400);
- handleChatScroll(host, event);
- expect(host.chatUserNearBottom).toBe(true);
- });
-
- it("sets chatUserNearBottom=true when distance is just under threshold", () => {
- const { host } = createScrollHost({});
- // distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
- const event = createScrollEvent(2000, 1151, 400);
- handleChatScroll(host, event);
- expect(host.chatUserNearBottom).toBe(true);
- });
-
- it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
- const { host } = createScrollHost({});
- // distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
- const event = createScrollEvent(2000, 1150, 400);
- handleChatScroll(host, event);
- expect(host.chatUserNearBottom).toBe(false);
- });
-
- it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
- const { host } = createScrollHost({});
- // distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
- const event = createScrollEvent(2000, 500, 400);
- handleChatScroll(host, event);
- expect(host.chatUserNearBottom).toBe(false);
- });
-
- it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
- const { host } = createScrollHost({});
- // distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
- // distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
- const event = createScrollEvent(2000, 1100, 400);
- handleChatScroll(host, event);
- expect(host.chatUserNearBottom).toBe(false);
+ it("updates near-bottom state across threshold boundaries", () => {
+ const cases = [
+ {
+ name: "clearly near bottom",
+ event: createScrollEvent(2000, 1600, 400),
+ expected: true,
+ },
+ {
+ name: "just under threshold",
+ event: createScrollEvent(2000, 1151, 400),
+ expected: true,
+ },
+ {
+ name: "exactly at threshold",
+ event: createScrollEvent(2000, 1150, 400),
+ expected: false,
+ },
+ {
+ name: "well above threshold",
+ event: createScrollEvent(2000, 500, 400),
+ expected: false,
+ },
+ {
+ name: "scrolled up beyond long message",
+ event: createScrollEvent(2000, 1100, 400),
+ expected: false,
+ },
+ ] as const;
+ for (const testCase of cases) {
+ const { host } = createScrollHost({});
+ handleChatScroll(host, testCase.event);
+ expect(host.chatUserNearBottom, testCase.name).toBe(testCase.expected);
+ }
});
});
@@ -121,85 +115,67 @@ describe("scheduleChatScroll", () => {
vi.restoreAllMocks();
});
- it("scrolls to bottom when user is near bottom (no force)", async () => {
- const { host, container } = createScrollHost({
- scrollHeight: 2000,
- scrollTop: 1600,
- clientHeight: 400,
- });
- // distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
- host.chatUserNearBottom = true;
+ it("respects near-bottom, force, and initial-load behavior", async () => {
+ const cases = [
+ {
+ name: "near-bottom auto-scroll",
+ scrollTop: 1600,
+ chatUserNearBottom: true,
+ chatHasAutoScrolled: false,
+ force: false,
+ expectedScrollsToBottom: true,
+ expectedNewMessagesBelow: false,
+ },
+ {
+ name: "scrolled-up no-force",
+ scrollTop: 500,
+ chatUserNearBottom: false,
+ chatHasAutoScrolled: false,
+ force: false,
+ expectedScrollsToBottom: false,
+ expectedNewMessagesBelow: true,
+ },
+ {
+ name: "scrolled-up force after initial load",
+ scrollTop: 500,
+ chatUserNearBottom: false,
+ chatHasAutoScrolled: true,
+ force: true,
+ expectedScrollsToBottom: false,
+ expectedNewMessagesBelow: true,
+ },
+ {
+ name: "scrolled-up force on initial load",
+ scrollTop: 500,
+ chatUserNearBottom: false,
+ chatHasAutoScrolled: false,
+ force: true,
+ expectedScrollsToBottom: true,
+ expectedNewMessagesBelow: false,
+ },
+ ] as const;
- scheduleChatScroll(host);
- await host.updateComplete;
+ for (const testCase of cases) {
+ const { host, container } = createScrollHost({
+ scrollHeight: 2000,
+ scrollTop: testCase.scrollTop,
+ clientHeight: 400,
+ });
+ host.chatUserNearBottom = testCase.chatUserNearBottom;
+ host.chatHasAutoScrolled = testCase.chatHasAutoScrolled;
+ host.chatNewMessagesBelow = false;
+ const originalScrollTop = container.scrollTop;
- expect(container.scrollTop).toBe(container.scrollHeight);
- });
+ scheduleChatScroll(host, testCase.force);
+ await host.updateComplete;
- it("does NOT scroll when user is scrolled up and no force", async () => {
- const { host, container } = createScrollHost({
- scrollHeight: 2000,
- scrollTop: 500,
- clientHeight: 400,
- });
- // distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
- host.chatUserNearBottom = false;
- const originalScrollTop = container.scrollTop;
-
- scheduleChatScroll(host);
- await host.updateComplete;
-
- expect(container.scrollTop).toBe(originalScrollTop);
- });
-
- it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
- const { host, container } = createScrollHost({
- scrollHeight: 2000,
- scrollTop: 500,
- clientHeight: 400,
- });
- // User has scrolled up — chatUserNearBottom is false
- host.chatUserNearBottom = false;
- host.chatHasAutoScrolled = true; // Already past initial load
- const originalScrollTop = container.scrollTop;
-
- scheduleChatScroll(host, true);
- await host.updateComplete;
-
- // force=true should still NOT override explicit user scroll-up after initial load
- expect(container.scrollTop).toBe(originalScrollTop);
- });
-
- it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
- const { host, container } = createScrollHost({
- scrollHeight: 2000,
- scrollTop: 500,
- clientHeight: 400,
- });
- host.chatUserNearBottom = false;
- host.chatHasAutoScrolled = false; // Initial load
-
- scheduleChatScroll(host, true);
- await host.updateComplete;
-
- // On initial load, force should work regardless
- expect(container.scrollTop).toBe(container.scrollHeight);
- });
-
- it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
- const { host } = createScrollHost({
- scrollHeight: 2000,
- scrollTop: 500,
- clientHeight: 400,
- });
- host.chatUserNearBottom = false;
- host.chatHasAutoScrolled = true;
- host.chatNewMessagesBelow = false;
-
- scheduleChatScroll(host);
- await host.updateComplete;
-
- expect(host.chatNewMessagesBelow).toBe(true);
+ if (testCase.expectedScrollsToBottom) {
+ expect(container.scrollTop, testCase.name).toBe(container.scrollHeight);
+ } else {
+ expect(container.scrollTop, testCase.name).toBe(originalScrollTop);
+ }
+ expect(host.chatNewMessagesBelow, testCase.name).toBe(testCase.expectedNewMessagesBelow);
+ }
});
});
diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts
index 239bdd213..24cf7f266 100644
--- a/ui/src/ui/format.test.ts
+++ b/ui/src/ui/format.test.ts
@@ -2,70 +2,75 @@ import { describe, expect, it } from "vitest";
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
describe("formatAgo", () => {
- it("returns 'in <1m' for timestamps less than 60s in the future", () => {
- expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
- });
-
- it("returns 'Xm from now' for future timestamps", () => {
- expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m");
- });
-
- it("returns 'Xh from now' for future timestamps", () => {
- expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("in 3h");
- });
-
- it("returns 'Xd from now' for future timestamps beyond 48h", () => {
- expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("in 3d");
- });
-
- it("returns 'Xs ago' for recent past timestamps", () => {
- expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("just now");
- });
-
- it("returns 'Xm ago' for past timestamps", () => {
- expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
- });
-
- it("returns 'n/a' for null/undefined", () => {
- expect(formatRelativeTimestamp(null)).toBe("n/a");
- expect(formatRelativeTimestamp(undefined)).toBe("n/a");
+ it("formats relative timestamps across future/past/null cases", () => {
+ const now = Date.now();
+ const cases = [
+ { name: "<1m future", input: now + 30_000, expected: "in <1m" },
+ { name: "minutes future", input: now + 5 * 60_000, expected: "in 5m" },
+ { name: "hours future", input: now + 3 * 60 * 60_000, expected: "in 3h" },
+ { name: "days future", input: now + 3 * 24 * 60 * 60_000, expected: "in 3d" },
+ { name: "recent past", input: now - 10_000, expected: "just now" },
+ { name: "minutes past", input: now - 5 * 60_000, expected: "5m ago" },
+ { name: "null", input: null, expected: "n/a" },
+ { name: "undefined", input: undefined, expected: "n/a" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(formatRelativeTimestamp(testCase.input), testCase.name).toBe(testCase.expected);
+ }
});
});
describe("stripThinkingTags", () => {
- it("strips … segments", () => {
- const input = ["", "secret", "", "", "Hello"].join("\n");
- expect(stripThinkingTags(input)).toBe("Hello");
- });
-
- it("strips … segments", () => {
- const input = ["", "secret", "", "", "Hello"].join("\n");
- expect(stripThinkingTags(input)).toBe("Hello");
- });
-
- it("keeps text when tags are unpaired", () => {
- expect(stripThinkingTags("\nsecret\nHello")).toBe("secret\nHello");
- expect(stripThinkingTags("Hello\n")).toBe("Hello\n");
- });
-
- it("returns original text when no tags exist", () => {
- expect(stripThinkingTags("Hello")).toBe("Hello");
- });
-
- it("strips … segments", () => {
- const input = "\n\nHello there\n\n";
- expect(stripThinkingTags(input)).toBe("Hello there\n\n");
- });
-
- it("strips mixed and tags", () => {
- const input = "reasoning\n\nHello";
- expect(stripThinkingTags(input)).toBe("Hello");
- });
-
- it("handles incomplete {
- // When streaming splits mid-tag, we may see ""
- // This should not crash and should handle gracefully
- expect(stripThinkingTags("")).toBe("Hello");
+ it("normalizes think/final tag variants", () => {
+ const cases = [
+ {
+ name: "strip think block",
+ input: ["", "secret", "", "", "Hello"].join("\n"),
+ expected: "Hello",
+ },
+ {
+ name: "strip thinking block",
+ input: ["", "secret", "", "", "Hello"].join("\n"),
+ expected: "Hello",
+ },
+ {
+ name: "unpaired think start",
+ input: "\nsecret\nHello",
+ expected: "secret\nHello",
+ },
+ {
+ name: "unpaired think end",
+ input: "Hello\n",
+ expected: "Hello\n",
+ },
+ {
+ name: "no tags",
+ input: "Hello",
+ expected: "Hello",
+ },
+ {
+ name: "strip final block",
+ input: "\n\nHello there\n\n",
+ expected: "Hello there\n\n",
+ },
+ {
+ name: "strip mixed think/final",
+ input: "reasoning\n\nHello",
+ expected: "Hello",
+ },
+ {
+ name: "incomplete final start",
+ input: "",
+ expected: "Hello",
+ },
+ ] as const;
+ for (const testCase of cases) {
+ expect(stripThinkingTags(testCase.input), testCase.name).toBe(testCase.expected);
+ }
});
});
diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts
index 4ff027934..79839ef16 100644
--- a/ui/src/ui/navigation.test.ts
+++ b/ui/src/ui/navigation.test.ts
@@ -26,17 +26,22 @@ describe("iconForTab", () => {
});
it("returns stable icons for known tabs", () => {
- expect(iconForTab("chat")).toBe("messageSquare");
- expect(iconForTab("overview")).toBe("barChart");
- expect(iconForTab("channels")).toBe("link");
- expect(iconForTab("instances")).toBe("radio");
- expect(iconForTab("sessions")).toBe("fileText");
- expect(iconForTab("cron")).toBe("loader");
- expect(iconForTab("skills")).toBe("zap");
- expect(iconForTab("nodes")).toBe("monitor");
- expect(iconForTab("config")).toBe("settings");
- expect(iconForTab("debug")).toBe("bug");
- expect(iconForTab("logs")).toBe("scrollText");
+ const cases = [
+ { tab: "chat", icon: "messageSquare" },
+ { tab: "overview", icon: "barChart" },
+ { tab: "channels", icon: "link" },
+ { tab: "instances", icon: "radio" },
+ { tab: "sessions", icon: "fileText" },
+ { tab: "cron", icon: "loader" },
+ { tab: "skills", icon: "zap" },
+ { tab: "nodes", icon: "monitor" },
+ { tab: "config", icon: "settings" },
+ { tab: "debug", icon: "bug" },
+ { tab: "logs", icon: "scrollText" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(iconForTab(testCase.tab), testCase.tab).toBe(testCase.icon);
+ }
});
it("returns a fallback icon for unknown tab", () => {
@@ -56,9 +61,14 @@ describe("titleForTab", () => {
});
it("returns expected titles", () => {
- expect(titleForTab("chat")).toBe("Chat");
- expect(titleForTab("overview")).toBe("Overview");
- expect(titleForTab("cron")).toBe("Cron Jobs");
+ const cases = [
+ { tab: "chat", title: "Chat" },
+ { tab: "overview", title: "Overview" },
+ { tab: "cron", title: "Cron Jobs" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(titleForTab(testCase.tab), testCase.tab).toBe(testCase.title);
+ }
});
});
@@ -77,108 +87,96 @@ describe("subtitleForTab", () => {
});
describe("normalizeBasePath", () => {
- it("returns empty string for falsy input", () => {
- expect(normalizeBasePath("")).toBe("");
- });
-
- it("adds leading slash if missing", () => {
- expect(normalizeBasePath("ui")).toBe("/ui");
- });
-
- it("removes trailing slash", () => {
- expect(normalizeBasePath("/ui/")).toBe("/ui");
- });
-
- it("returns empty string for root path", () => {
- expect(normalizeBasePath("/")).toBe("");
- });
-
- it("handles nested paths", () => {
- expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw");
+ it("normalizes base-path variants", () => {
+ const cases = [
+ { input: "", expected: "" },
+ { input: "ui", expected: "/ui" },
+ { input: "/ui/", expected: "/ui" },
+ { input: "/", expected: "" },
+ { input: "/apps/openclaw", expected: "/apps/openclaw" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(normalizeBasePath(testCase.input), testCase.input).toBe(testCase.expected);
+ }
});
});
describe("normalizePath", () => {
- it("returns / for falsy input", () => {
- expect(normalizePath("")).toBe("/");
- });
-
- it("adds leading slash if missing", () => {
- expect(normalizePath("chat")).toBe("/chat");
- });
-
- it("removes trailing slash except for root", () => {
- expect(normalizePath("/chat/")).toBe("/chat");
- expect(normalizePath("/")).toBe("/");
+ it("normalizes paths", () => {
+ const cases = [
+ { input: "", expected: "/" },
+ { input: "chat", expected: "/chat" },
+ { input: "/chat/", expected: "/chat" },
+ { input: "/", expected: "/" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(normalizePath(testCase.input), testCase.input).toBe(testCase.expected);
+ }
});
});
describe("pathForTab", () => {
- it("returns correct path without base", () => {
- expect(pathForTab("chat")).toBe("/chat");
- expect(pathForTab("overview")).toBe("/overview");
- });
-
- it("prepends base path", () => {
- expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
- expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions");
+ it("builds tab paths with optional bases", () => {
+ const cases = [
+ { tab: "chat", base: undefined, expected: "/chat" },
+ { tab: "overview", base: undefined, expected: "/overview" },
+ { tab: "chat", base: "/ui", expected: "/ui/chat" },
+ { tab: "sessions", base: "/apps/openclaw", expected: "/apps/openclaw/sessions" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(
+ pathForTab(testCase.tab, testCase.base),
+ `${testCase.tab}:${testCase.base ?? "root"}`,
+ ).toBe(testCase.expected);
+ }
});
});
describe("tabFromPath", () => {
- it("returns tab for valid path", () => {
- expect(tabFromPath("/chat")).toBe("chat");
- expect(tabFromPath("/overview")).toBe("overview");
- expect(tabFromPath("/sessions")).toBe("sessions");
- });
-
- it("returns chat for root path", () => {
- expect(tabFromPath("/")).toBe("chat");
- });
-
- it("handles base paths", () => {
- expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
- expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions");
- });
-
- it("returns null for unknown path", () => {
- expect(tabFromPath("/unknown")).toBeNull();
- });
-
- it("is case-insensitive", () => {
- expect(tabFromPath("/CHAT")).toBe("chat");
- expect(tabFromPath("/Overview")).toBe("overview");
+ it("resolves tabs from path variants", () => {
+ const cases = [
+ { path: "/chat", base: undefined, expected: "chat" },
+ { path: "/overview", base: undefined, expected: "overview" },
+ { path: "/sessions", base: undefined, expected: "sessions" },
+ { path: "/", base: undefined, expected: "chat" },
+ { path: "/ui/chat", base: "/ui", expected: "chat" },
+ { path: "/apps/openclaw/sessions", base: "/apps/openclaw", expected: "sessions" },
+ { path: "/unknown", base: undefined, expected: null },
+ { path: "/CHAT", base: undefined, expected: "chat" },
+ { path: "/Overview", base: undefined, expected: "overview" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(
+ tabFromPath(testCase.path, testCase.base),
+ `${testCase.path}:${testCase.base ?? "root"}`,
+ ).toBe(testCase.expected);
+ }
});
});
describe("inferBasePathFromPathname", () => {
- it("returns empty string for root", () => {
- expect(inferBasePathFromPathname("/")).toBe("");
- });
-
- it("returns empty string for direct tab path", () => {
- expect(inferBasePathFromPathname("/chat")).toBe("");
- expect(inferBasePathFromPathname("/overview")).toBe("");
- });
-
- it("infers base path from nested paths", () => {
- expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui");
- expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw");
- });
-
- it("handles index.html suffix", () => {
- expect(inferBasePathFromPathname("/index.html")).toBe("");
- expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
+ it("infers base-path variants from pathname", () => {
+ const cases = [
+ { path: "/", expected: "" },
+ { path: "/chat", expected: "" },
+ { path: "/overview", expected: "" },
+ { path: "/ui/chat", expected: "/ui" },
+ { path: "/apps/openclaw/sessions", expected: "/apps/openclaw" },
+ { path: "/index.html", expected: "" },
+ { path: "/ui/index.html", expected: "/ui" },
+ ] as const;
+ for (const testCase of cases) {
+ expect(inferBasePathFromPathname(testCase.path), testCase.path).toBe(testCase.expected);
+ }
});
});
describe("TAB_GROUPS", () => {
it("contains all expected groups", () => {
- const labels = TAB_GROUPS.map((g) => g.label);
- expect(labels).toContain("Chat");
- expect(labels).toContain("Control");
- expect(labels).toContain("Agent");
- expect(labels).toContain("Settings");
+ const labels = TAB_GROUPS.map((g) => g.label.toLowerCase());
+ for (const expected of ["chat", "control", "agent", "settings"]) {
+ expect(labels).toContain(expected);
+ }
});
it("all tabs are unique", () => {