Files
lam-agent-vicinae/src/__tests__/utils.test.ts
T
adrianbonpin cfb9d33ecf feat: newest message first, Raycast-style conversation sidebar
Three changes from comparing against Raycast's AI chat.

1. The transcript now reads newest message first, so an answer sits above
   the question it responds to. The search bar is pinned to the top of
   the window, so the most recent thing belongs directly under it rather
   than below the fold. buildThreadMarkdown regroups messages back into
   exchanges, so the horizontal rule separates one exchange from the next
   instead of dividing every single message.

2. The sidebar is titles only, grouped into recency sections (Today,
   Yesterday, This week, Older). Raycast does this, and it fixes rows
   eliding down to "Hello l..." -- a subtitle and a timestamp on a 38px
   row left the title almost no width. The check icon is what marks the
   open conversation now.

3. The status bar names the open conversation, which is the nearest thing
   Vicinae has to the header Raycast puts above its transcript.

Also removes dead code found on the way: ThreadMessage.preview was no
longer read by anything once the rows became conversations. Rather than
delete the plumbing outright, the activity line it carried ("Searching
the web") moved into the transcript placeholder and the search bar, so
tool activity is still visible -- it just is not a per-row preview any
more.

Verified on screen: answer above question, one rule per exchange, the
sidebar showing a single "Today" section with a title-only row, and the
status bar naming the conversation. typecheck, eslint, 113 tests and
vici build all pass.
2026-10-04 05:15:17 +08:00

101 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
errorMessage,
formatRelativeTime,
recencyGroup,
titleFromPrompt,
truncate,
} from "../lib/utils";
describe("truncate", () => {
it("collapses whitespace so list rows never wrap unexpectedly", () => {
expect(truncate("hello\n\n world", 40)).toBe("hello world");
});
it("leaves short text alone", () => {
expect(truncate("short", 40)).toBe("short");
});
it("clips with an ellipsis at the requested width", () => {
const result = truncate("a".repeat(100), 10);
expect(result).toBe(`${"a".repeat(9)}…`);
expect(result).toHaveLength(10);
});
});
describe("formatRelativeTime", () => {
const now = 1_700_000_000_000;
it("reports very recent timestamps as just now", () => {
expect(formatRelativeTime(now - 5_000, now)).toBe("just now");
});
it("reports minutes and hours", () => {
expect(formatRelativeTime(now - 12 * 60_000, now)).toBe("12m ago");
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago");
});
it("reports days within the last week", () => {
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago");
});
it("treats clock skew as just now rather than a negative age", () => {
expect(formatRelativeTime(now + 60_000, now)).toBe("just now");
});
});
describe("recencyGroup", () => {
// Fixed local noon so the day boundaries are unambiguous on any machine.
const now = new Date(2026, 9, 4, 12, 0, 0).getTime();
const DAY = 86_400_000;
it("buckets today", () => {
expect(recencyGroup(now - 60_000, now)).toBe("Today");
});
it("buckets yesterday", () => {
expect(recencyGroup(new Date(2026, 9, 3, 12, 0, 0).getTime(), now)).toBe("Yesterday");
});
it("buckets the rest of the week", () => {
expect(recencyGroup(now - 3 * DAY, now)).toBe("This week");
expect(recencyGroup(now - 6 * DAY, now)).toBe("This week");
});
it("buckets anything older", () => {
expect(recencyGroup(now - 30 * DAY, now)).toBe("Older");
});
it("does not bucket the small hours of today as yesterday", () => {
// Just after midnight is still today, even though it is under 24h ago.
const justAfterMidnight = new Date(2026, 9, 4, 0, 5, 0).getTime();
expect(recencyGroup(justAfterMidnight, now)).toBe("Today");
});
});
describe("titleFromPrompt", () => {
it("uses the first non-empty line, stripped of markdown decoration", () => {
expect(titleFromPrompt("\n\n## How do I deploy?\nmore text", 60)).toBe("How do I deploy?");
});
it("falls back to a placeholder for an empty prompt", () => {
expect(titleFromPrompt(" \n ", 60)).toBe("New chat");
});
});
describe("errorMessage", () => {
it("unwraps Error instances", () => {
expect(errorMessage(new Error("boom"))).toBe("boom");
});
it("passes strings through", () => {
expect(errorMessage("plain failure")).toBe("plain failure");
});
it("survives values that cannot be serialised", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(typeof errorMessage(circular)).toBe("string");
});
});