feat: newest message first, Raycast-style conversation sidebar
Three changes from comparing against Raycast's AI chat.
1. The transcript now reads newest message first, so an answer sits above
the question it responds to. The search bar is pinned to the top of
the window, so the most recent thing belongs directly under it rather
than below the fold. buildThreadMarkdown regroups messages back into
exchanges, so the horizontal rule separates one exchange from the next
instead of dividing every single message.
2. The sidebar is titles only, grouped into recency sections (Today,
Yesterday, This week, Older). Raycast does this, and it fixes rows
eliding down to "Hello l..." -- a subtitle and a timestamp on a 38px
row left the title almost no width. The check icon is what marks the
open conversation now.
3. The status bar names the open conversation, which is the nearest thing
Vicinae has to the header Raycast puts above its transcript.
Also removes dead code found on the way: ThreadMessage.preview was no
longer read by anything once the rows became conversations. Rather than
delete the plumbing outright, the activity line it carried ("Searching
the web") moved into the transcript placeholder and the search bar, so
tool activity is still visible -- it just is not a per-row preview any
more.
Verified on screen: answer above question, one rule per exchange, the
sidebar showing a single "Today" section with a title-only row, and the
status bar naming the conversation. typecheck, eslint, 113 tests and
vici build all pass.
This commit is contained in:
@@ -145,7 +145,9 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
**Return adapts to the search bar.** With text in it, `↩` sends. Empty — where sending would be a no-op anyway — `↩` opens the highlighted conversation. Every row carries that conversation's transcript as its detail, so moving the selection previews it.
|
||||
|
||||
**Conversations are ordered most-recently-updated first.** Vicinae resets list selection to the first row on every model change (`GenericListView.selectFirstOnReset`), and streaming changes the model on every flush. Ordering by recency keeps the conversation being chatted in at row zero, which is where the selection lands.
|
||||
**Conversations are ordered most-recently-updated first, and the sidebar shows titles only, grouped by recency.** Vicinae resets list selection to the first row on every model change (`GenericListView.selectFirstOnReset`), and streaming changes the model on every flush. Ordering by recency keeps the conversation being chatted in at row zero, which is where the selection lands. Raycast's sidebar works the same way, and grouping by day buys the recency cue that a timestamp on every row was trying to convey — while leaving the title room to actually be readable.
|
||||
|
||||
**The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message — an answer, once one has been written — is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message.
|
||||
|
||||
**The transcript is text-only.** Speaker labels are plain `You` / `Lam`, with the question dimmed as a blockquote and the answer as body text. Icons are not possible there: `MdImage` renders images block-level, centred, and capped at 200px tall, and every candidate source was tried — `icon://person`, a raw icon slug, and a `file://` asset path — and each fell back to rendering its alt text as a caption.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?");
|
||||
|
||||
@@ -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,33 +260,28 @@ 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 (
|
||||
{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}
|
||||
subtitle={row.preview}
|
||||
accessories={accessories}
|
||||
// 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}
|
||||
@@ -275,8 +290,9 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
}
|
||||
actions={rowActions(row)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</List.Section>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-17
@@ -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.
|
||||
|
||||
+23
-35
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user