feat: newest message first, Raycast-style conversation sidebar (#7)
This commit was merged in pull request #7.
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
STREAMING_CURSOR,
|
||||
USER_LABEL,
|
||||
buildThreadMarkdown,
|
||||
previewText,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
@@ -14,7 +13,6 @@ function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">
|
||||
return {
|
||||
id: "m1",
|
||||
text: "",
|
||||
preview: "",
|
||||
createdAt: 0,
|
||||
isStreaming: false,
|
||||
thinking: "",
|
||||
@@ -46,6 +44,21 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(markdown).toContain("4");
|
||||
});
|
||||
|
||||
it("groups an answer with its question, dividing only between exchanges", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
|
||||
message({ id: "t2:user", role: "user", text: "second question" }),
|
||||
message({ id: "t1:assistant", role: "assistant", text: "first answer" }),
|
||||
message({ id: "t1:user", role: "user", text: "first question" }),
|
||||
]);
|
||||
|
||||
// One rule between the two exchanges, none inside one.
|
||||
expect(markdown.match(/^---$/gm)?.length).toBe(1);
|
||||
// And the answer precedes the question it belongs to.
|
||||
expect(markdown.indexOf("second answer")).toBeLessThan(markdown.indexOf("second question"));
|
||||
expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer"));
|
||||
});
|
||||
|
||||
it("labels each speaker, in order", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "user", text: "q" }),
|
||||
@@ -89,6 +102,15 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(finished).not.toContain(STREAMING_CURSOR);
|
||||
});
|
||||
|
||||
it("shows the activity line while a tool runs", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", isStreaming: true, status: "Searching the web" }),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("_Searching the web…_");
|
||||
expect(markdown).not.toContain("Thinking");
|
||||
});
|
||||
|
||||
it("shows a placeholder while an answer has no text yet", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking");
|
||||
expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response");
|
||||
@@ -125,20 +147,6 @@ describe("buildThreadMarkdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewText", () => {
|
||||
it("collapses newlines so a preview stays on one row", () => {
|
||||
expect(previewText("a\n\nb", 40)).toBe("a b");
|
||||
});
|
||||
|
||||
it("clips with an ellipsis", () => {
|
||||
expect(previewText("a".repeat(50), 5)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("leaves short text alone", () => {
|
||||
expect(previewText("hi", 40)).toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionToPlainText", () => {
|
||||
function turn(prompt: string, response: string, error?: string): ChatTurn {
|
||||
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MESSAGE_PREVIEW_LENGTH, buildThread, type LiveState } from "../lib/thread";
|
||||
import { buildThread, type LiveState } from "../lib/thread";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
|
||||
const idle: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||
@@ -22,25 +22,41 @@ describe("buildThread", () => {
|
||||
expect(buildThread([], idle)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flattens a turn into a user message followed by an assistant message", () => {
|
||||
it("puts the answer above its question", () => {
|
||||
const thread = buildThread([turn("t1", "hello", "hi there")], idle);
|
||||
|
||||
expect(thread.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(thread[0].text).toBe("hello");
|
||||
expect(thread[1].text).toBe("hi there");
|
||||
expect(thread.map((message) => message.role)).toEqual(["assistant", "user"]);
|
||||
expect(thread[0].text).toBe("hi there");
|
||||
expect(thread[1].text).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps messages in chronological order across turns", () => {
|
||||
it("shows the newest message first, regardless of who sent it", () => {
|
||||
// The search bar is at the top of the window, so the latest answer should be
|
||||
// the first thing under it rather than below the fold.
|
||||
const thread = buildThread(
|
||||
[turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")],
|
||||
idle,
|
||||
);
|
||||
|
||||
expect(thread.map((message) => message.text)).toEqual([
|
||||
"first question",
|
||||
"first answer",
|
||||
"second question",
|
||||
"second answer",
|
||||
"second question",
|
||||
"first answer",
|
||||
"first question",
|
||||
]);
|
||||
});
|
||||
|
||||
it("alternates assistant then user", () => {
|
||||
const thread = buildThread(
|
||||
[turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")],
|
||||
idle,
|
||||
);
|
||||
|
||||
expect(thread.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -51,56 +67,52 @@ describe("buildThread", () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("builds one-line previews", () => {
|
||||
const thread = buildThread([turn("t1", "hello\n\nworld", "a\nlong\n\nanswer")], idle);
|
||||
|
||||
expect(thread[0].preview).toBe("hello world");
|
||||
expect(thread[1].preview).toBe("a long answer");
|
||||
});
|
||||
|
||||
it("clips a preview longer than the row can show", () => {
|
||||
const long = "x".repeat(MESSAGE_PREVIEW_LENGTH + 50);
|
||||
const thread = buildThread([turn("t1", long, long)], idle);
|
||||
|
||||
expect(thread[0].preview).toHaveLength(MESSAGE_PREVIEW_LENGTH);
|
||||
});
|
||||
|
||||
it("streams the newest turn only", () => {
|
||||
it("streams the newest turn only, and puts it first", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "streaming now", thinking: "hmm" };
|
||||
const thread = buildThread([turn("t1", "old", "old answer"), turn("t2", "new", "")], live);
|
||||
|
||||
// The older turn stays put...
|
||||
// The in-flight answer leads...
|
||||
expect(thread[0].role).toBe("assistant");
|
||||
expect(thread[0].isStreaming).toBe(true);
|
||||
expect(thread[0].text).toBe("streaming now");
|
||||
expect(thread[0].thinking).toBe("hmm");
|
||||
|
||||
expect(thread[1].role).toBe("user");
|
||||
expect(thread[1].text).toBe("new");
|
||||
expect(thread[1].isStreaming).toBe(false);
|
||||
expect(thread[1].text).toBe("old answer");
|
||||
|
||||
// ...and the newest assistant message reads from the live buffer.
|
||||
expect(thread[3].isStreaming).toBe(true);
|
||||
expect(thread[3].text).toBe("streaming now");
|
||||
expect(thread[3].thinking).toBe("hmm");
|
||||
|
||||
// The user's line is never "streaming".
|
||||
// ...and the older turn follows, untouched.
|
||||
expect(thread[2].text).toBe("old answer");
|
||||
expect(thread[3].text).toBe("old");
|
||||
expect(thread[2].isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the activity line while an answer has no text yet", () => {
|
||||
it("carries the activity line while a streaming answer has no text yet", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" };
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Searching the web");
|
||||
expect(thread[0].status).toBe("Searching the web");
|
||||
});
|
||||
|
||||
it("falls back to a generic label while thinking", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "" };
|
||||
it("drops the activity line once text has arrived", () => {
|
||||
// The placeholder it feeds is only rendered when there is nothing to read.
|
||||
const live: LiveState = {
|
||||
isStreaming: true,
|
||||
response: "partial",
|
||||
thinking: "",
|
||||
status: "Searching the web",
|
||||
};
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Thinking…");
|
||||
expect(thread[0].status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("previews a stored error when a finished turn has no answer", () => {
|
||||
const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle);
|
||||
it("does not carry an activity line on a finished turn", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" };
|
||||
const thread = buildThread([turn("t1", "old", "done"), turn("t2", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Request failed");
|
||||
expect(thread[1].error).toBe("Request failed");
|
||||
// Index 2 is the older, finished exchange's answer.
|
||||
expect(thread[2].status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries tool calls onto the assistant message", () => {
|
||||
@@ -109,8 +121,14 @@ describe("buildThread", () => {
|
||||
});
|
||||
const thread = buildThread([withTools], idle);
|
||||
|
||||
expect(thread[0].toolCalls).toEqual([]);
|
||||
expect(thread[1].toolCalls).toHaveLength(1);
|
||||
expect(thread[1].toolCalls[0].name).toBe("web_search");
|
||||
expect(thread[0].toolCalls).toHaveLength(1);
|
||||
expect(thread[0].toolCalls[0].name).toBe("web_search");
|
||||
expect(thread[1].toolCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries a stored error onto a finished turn", () => {
|
||||
const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle);
|
||||
|
||||
expect(thread[0].error).toBe("Request failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { errorMessage, formatRelativeTime, titleFromPrompt, truncate } from "../lib/utils";
|
||||
import {
|
||||
errorMessage,
|
||||
formatRelativeTime,
|
||||
recencyGroup,
|
||||
titleFromPrompt,
|
||||
truncate,
|
||||
} from "../lib/utils";
|
||||
|
||||
describe("truncate", () => {
|
||||
it("collapses whitespace so list rows never wrap unexpectedly", () => {
|
||||
@@ -38,6 +44,35 @@ describe("formatRelativeTime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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?");
|
||||
|
||||
Reference in New Issue
Block a user