feat: newest message first, Raycast-style conversation sidebar (#7)

This commit was merged in pull request #7.
This commit is contained in:
2026-10-03 21:15:44 +00:00
parent 9b35fcef11
commit ffe2c6b7ed
9 changed files with 300 additions and 187 deletions
+62 -44
View File
@@ -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");
});
});