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
+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.