feat: newest message first, Raycast-style conversation sidebar (#7)
This commit was merged in pull request #7.
This commit is contained in:
@@ -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
@@ -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
@@ -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