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 -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";
}