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
+24 -16
View File
@@ -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 };