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
+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>
);
}