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 };
+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");
});
});
+36 -1
View File
@@ -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?");
+86 -70
View File
@@ -6,31 +6,39 @@ import { getLamPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { buildThread, type LiveState } from "../thread";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { formatRelativeTime } from "../utils";
import { recencyGroup, truncate } from "../utils";
/**
* The chat surface: conversations on the left, the open conversation on the right.
*
* Most of this is a consequence of how Vicinae renders lists rather than a free
* choice:
* Modelled on Raycast's AI chat, as far as Vicinae allows:
*
* - The sidebar lists conversations by title only, grouped into recency
* sections. Raycast does the same, and it buys the recency cue that
* per-row timestamps were trying to convey without putting a timestamp on
* every single row.
* - The transcript keeps user and assistant visually distinct. Raycast uses
* right-aligned bubbles; markdown offers no alignment control, so a dimmed
* blockquote for the question and body text for the answer is the available
* equivalent.
* - Raycast pins its composer to the bottom. Vicinae's search bar is fixed at
* the top of the window, so that part cannot be matched.
*
* Most of the rest is a consequence of how Vicinae renders lists:
*
* - The search bar is the composer. It is the only always-focused text input in
* a `List`, which is what makes "hit a hotkey, then just start typing" work.
* - `filtering` is off. This list is a conversation switcher, so the builtin
* fuzzy filter would hide rows as the user types their question.
* - The left column lists conversations; the right pane is the transcript. The
* two panes are not redundant because they show different things.
* - Every row carries that conversation's transcript as its detail, so moving
* the selection previews a conversation.
* - `Detail` accepts no typed input — `onSearchTextChange` exists only on
* `List`, `Grid`, `Form` and `Dropdown` — so the left column cannot be
* dropped: it *is* the text box.
* - `filtering` is off, because the left column is a conversation switcher — the
* builtin fuzzy filter would hide rows as the user types their question.
* - `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`,
* `Grid`, `Form` and `Dropdown`), so the left column cannot be dropped: it is
* the text box.
*
* Ordering matters here. Vicinae resets list selection to the first row
* whenever the model changes (`GenericListView.selectFirstOnReset`), and
* streaming changes the model on every flush. Sessions are therefore ordered by
* most recently updated, which keeps the conversation being chatted in at row
* zero where the selection lands.
* Ordering matters. Vicinae resets list selection to the first row whenever the
* model changes (`GenericListView.selectFirstOnReset`), and streaming changes
* the model on every flush. Conversations are therefore ordered by most
* recently updated, which keeps the one being chatted in at row zero where the
* selection lands.
*/
const IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
@@ -50,7 +58,6 @@ interface StoredConversation {
interface SessionRow {
id: string;
title: string;
preview: string;
updatedAt: number;
isActive: boolean;
/** Precomputed so a render does not rebuild every stored transcript. */
@@ -71,7 +78,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
/**
* Loads every conversation in full so each row can carry its own transcript
* as a detail. Only the index is stored separately, so this reads the bodies
* once on mount and after each turn rather than per render.
* once on mount and after each turn rather than on every render.
*/
const reload = useCallback(() => {
void (async () => {
@@ -139,7 +146,6 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const mapped: SessionRow[] = base.map((conversation) => ({
id: conversation.summary.id,
title: conversation.summary.title,
preview: conversation.summary.preview,
updatedAt: conversation.summary.updatedAt,
isActive: conversation.summary.id === activeId,
markdown: buildThreadMarkdown(buildThread(conversation.turns, IDLE)),
@@ -150,18 +156,28 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const active = summarizeSession(chat.session);
return [
{
id: active.id,
title: active.title,
preview: active.preview,
updatedAt: active.updatedAt,
isActive: true,
markdown: "",
},
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true, markdown: "" },
...mapped,
];
}, [stored, activeId, chat.session]);
/**
* Groups the sidebar into recency sections. Rows are already sorted by
* recency, so each group stays contiguous and can be built in one pass.
*/
const sections = useMemo(() => {
const grouped: { title: string; rows: SessionRow[] }[] = [];
for (const row of rows) {
const label = recencyGroup(row.updatedAt);
const current = grouped[grouped.length - 1];
if (current && current.title === label) current.rows.push(row);
else grouped.push({ title: label, rows: [row] });
}
return grouped;
}, [rows]);
const openSession = useCallback(
(id: string) => {
void loadSession(id).then((session) => {
@@ -195,6 +211,15 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
const newChatAction = (
<Action
title="New Chat"
icon={Icon.Plus}
shortcut={{ key: "n", modifiers: ["cmd"] }}
onAction={chat.newSession}
/>
);
/**
* The primary action adapts to the search bar. With text in it, Return sends;
* with it empty — where sending would be a no-op anyway — Return opens the
@@ -202,23 +227,14 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
*/
const rowActions = (row: SessionRow) => {
const openAction = (
<Action
title="Open Conversation"
icon={Icon.CheckCircle}
onAction={() => openSession(row.id)}
/>
<Action title="Open Conversation" icon={Icon.CheckCircle} onAction={() => openSession(row.id)} />
);
return (
<ActionPanel>
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
{hasPrompt ? openAction : null}
<Action
title="New Chat"
icon={Icon.Plus}
shortcut={{ key: "n", modifiers: ["cmd"] }}
onAction={chat.newSession}
/>
{newChatAction}
<Action
title="Delete Conversation"
icon={Icon.Trash}
@@ -233,6 +249,10 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
}
const placeholder = chat.status
? `${chat.status} — press ⏎ to stop`
: "Lam is thinking — press ⏎ to stop";
return (
<List
isLoading={chat.isStreaming}
@@ -240,43 +260,39 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
filtering={false}
searchText={input}
onSearchTextChange={setInput}
navigationTitle="Lam"
searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
// Raycast puts the conversation title in a header; Vicinae's nearest
// equivalent is the status-bar title, so the open chat is named there too.
navigationTitle={truncate(chat.session.title, 40)}
searchBarPlaceholder={chat.isStreaming ? placeholder : "Ask Lam anything…"}
actions={
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
<Action
title="New Chat"
icon={Icon.Plus}
shortcut={{ key: "n", modifiers: ["cmd"] }}
onAction={chat.newSession}
/>
{newChatAction}
</ActionPanel>
}
>
{rows.map((row) => {
const accessories: List.Item.Accessory[] = [];
if (row.isActive) accessories.push({ tag: "Open" });
accessories.push({ text: formatRelativeTime(row.updatedAt) });
return (
<List.Item
key={row.id}
id={row.id}
icon={row.isActive ? Icon.CheckCircle : Icon.SpeechBubble}
title={row.title}
subtitle={row.preview}
accessories={accessories}
detail={
<List.Item.Detail
markdown={row.isActive ? threadMarkdown : row.markdown}
isLoading={row.isActive && chat.isStreaming}
/>
}
actions={rowActions(row)}
/>
);
})}
{sections.map((section) => (
<List.Section key={section.title} title={section.title}>
{section.rows.map((row) => (
<List.Item
key={row.id}
id={row.id}
icon={row.isActive ? Icon.CheckCircle : Icon.SpeechBubble}
title={row.title}
// Title only. Raycast's sidebar shows nothing else, and stacking a
// subtitle plus a timestamp on a 38px row elided the title down to
// a few characters. The check icon is what marks the open chat.
detail={
<List.Item.Detail
markdown={row.isActive ? threadMarkdown : row.markdown}
isLoading={row.isActive && chat.isStreaming}
/>
}
actions={rowActions(row)}
/>
))}
</List.Section>
))}
</List>
);
}
+42 -17
View File
@@ -8,8 +8,9 @@ import type { ChatTurn, ThreadMessage } from "./types";
*
* The user/assistant distinction is carried by markdown itself: user messages
* render as blockquotes, which the renderer draws in a dimmed italic style,
* while answers render as normal body text. That mirrors how the list rows
* dim the question and brighten the answer, so the two panes agree.
* while answers render as normal body text. Raycast's chat uses right-aligned
* bubbles for the same job, but markdown offers no alignment control, so the
* dim/bright split is the available equivalent.
*/
export const STREAMING_CURSOR = "▍";
@@ -22,8 +23,7 @@ export const STREAMING_CURSOR = "▍";
* of the candidate sources resolve inside markdown — `icon://person`, a raw
* icon slug, and a `file://` asset path were all tried and each one fell back
* to rendering the alt text as a caption. An icon therefore cannot sit inline
* beside a message the way a text label can. The dim/bright styling carries
* most of the distinction regardless.
* beside a message the way a text label can.
*/
export const USER_LABEL = "You";
export const ASSISTANT_LABEL = "Lam";
@@ -36,19 +36,15 @@ export function quote(text: string): string {
.join("\n");
}
/** A short one-line preview of a message, for a list row. */
export function previewText(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return collapsed;
return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
}
function renderMessage(message: ThreadMessage): string {
if (message.role === "user") {
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
}
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing.
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
const body = message.text.trim() || (message.isStreaming ? placeholder : "_No response._");
const sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
if (message.error) {
@@ -70,22 +66,51 @@ function renderMessage(message: ThreadMessage): string {
}
/**
* Renders the whole conversation.
* Renders the whole conversation, newest message first.
*
* The detail panel shows the full thread rather than just the selected message
* on purpose. Vicinae's list resets its selection to the first row whenever the
* The detail panel shows the entire thread rather than just one message on
* purpose. Vicinae's list resets its selection to the first row whenever the
* model changes, and streaming changes it on every flush, so the selected row
* is not something the extension can rely on staying put while an answer
* arrives. Showing the thread means the live answer is always on screen.
* arrives. Showing the whole thread means the live answer is always on screen.
*
* Messages are grouped back into exchanges for layout: an answer and the
* question it responds to belong to the same block, so the horizontal rule only
* separates one exchange from the next rather than dividing every single
* message.
*/
export function buildThreadMarkdown(messages: ThreadMessage[]): string {
if (messages.length === 0) return "";
return messages.map(renderMessage).join("\n\n---\n\n");
const blocks: string[] = [];
let currentTurn = "";
let buffer: string[] = [];
const flush = () => {
if (buffer.length > 0) blocks.push(buffer.join("\n\n"));
buffer = [];
};
for (const message of messages) {
// `turnId:role`, so the turn is the id without its role suffix.
const turnId = message.id.replace(/:(assistant|user)$/, "");
if (turnId !== currentTurn) {
flush();
currentTurn = turnId;
}
buffer.push(renderMessage(message));
}
flush();
return blocks.join("\n\n---\n\n");
}
/**
* Flattens a whole conversation into plain text for the clipboard.
*
* Unlike the transcript this reads oldest-first: as a standalone document, a
* conversation is easier to read in the order it happened.
*
* This has to load the full transcript rather than reuse the index summary —
* copying a truncated preview and calling it "the conversation" would be a
* quiet lie.
+24 -36
View File
@@ -1,72 +1,60 @@
import { previewText } from "./markdown";
import type { ChatTurn, ThreadMessage } from "./types";
/**
* Flattens stored turns into the flat message list the transcript renders.
*
* Storage keeps a question and its answer together as a turn, because that is
* what gets replayed to the model. The UI needs the opposite shape: a flat
* sequence so the user's line and Lam's line can each carry their own icon and
* weight. This is the one place that translation happens.
* what gets replayed to the model. The transcript needs a flat sequence, newest
* message first. This is the one place that translation happens.
*/
/** Rows are a single line, so previews are clipped well before the renderer elides them. */
export const MESSAGE_PREVIEW_LENGTH = 140;
export interface LiveState {
isStreaming: boolean;
/** Partial answer for the turn currently in flight. */
response: string;
thinking: string;
/** Activity line, e.g. "Searching the web". */
/** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */
status?: string;
}
export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] {
const messages: ThreadMessage[] = [];
const lastIndex = turns.length - 1;
turns.forEach((turn, index) => {
messages.push({
id: `${turn.id}:user`,
role: "user",
text: turn.prompt,
preview: previewText(turn.prompt, MESSAGE_PREVIEW_LENGTH),
createdAt: turn.createdAt,
isStreaming: false,
thinking: "",
toolCalls: [],
});
// Strictly newest message first. The search bar sits at the top of the window,
// so the most recent thing — an answer, once one has been written — should be
// the first line under it. A consequence is that within an exchange the answer
// comes before its question, which is the intended reading order here.
for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index];
// Only the newest turn can be mid-flight.
const isLive = index === lastIndex && live.isStreaming;
const isLive = index === turns.length - 1 && live.isStreaming;
const text = isLive ? live.response : turn.response;
messages.push({
id: `${turn.id}:assistant`,
role: "assistant",
text,
preview: assistantPreview(text, isLive, live.status, turn.error),
createdAt: turn.createdAt,
isStreaming: isLive,
thinking: isLive ? live.thinking : turn.thinking,
toolCalls: turn.toolCalls,
error: isLive ? undefined : turn.error,
// Only while there is nothing to read yet. Once tokens land, the
// placeholder it feeds is gone anyway.
status: isLive && text.length === 0 ? live.status : undefined,
});
});
messages.push({
id: `${turn.id}:user`,
role: "user",
text: turn.prompt,
createdAt: turn.createdAt,
isStreaming: false,
thinking: "",
toolCalls: [],
});
}
return messages;
}
/**
* The row label for an answer.
*
* Before the first token lands there is nothing to preview, so the activity
* line stands in — otherwise the row would sit blank while the model works.
*/
function assistantPreview(text: string, isLive: boolean, status: string | undefined, error?: string): string {
if (text.trim().length > 0) return previewText(text, MESSAGE_PREVIEW_LENGTH);
if (isLive) return status ?? "Thinking…";
if (error) return error;
return "No response";
}
+2 -2
View File
@@ -97,11 +97,11 @@ export interface ThreadMessage {
role: "user" | "assistant";
/** Full text, used for the detail panel and for copying. */
text: string;
/** Single-line text for the list row, because rows are one line tall. */
preview: string;
createdAt: number;
isStreaming: boolean;
thinking: string;
toolCalls: ToolCallRecord[];
error?: string;
/** Activity line shown while this message is streaming, e.g. "Searching the web". */
status?: string;
}
+21
View File
@@ -29,6 +29,27 @@ export function formatRelativeTime(timestamp: number, now: number = Date.now()):
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export type RecencyGroup = "Today" | "Yesterday" | "This week" | "Older";
/**
* Buckets a timestamp for the conversation sidebar.
*
* Raycast groups its sidebar this way, and it buys the grouping that per-row
* timestamps were trying to convey — without putting a timestamp on every row.
*/
export function recencyGroup(timestamp: number, now: number = Date.now()): RecencyGroup {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const start = startOfToday.getTime();
const DAY = 86_400_000;
if (timestamp >= start) return "Today";
if (timestamp >= start - DAY) return "Yesterday";
if (timestamp >= start - 7 * DAY) return "This week";
return "Older";
}
/** Pulls a human-readable message out of anything that can be thrown. */
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;