feat: conversations on the left, the transcript on the right (#6)

This commit was merged in pull request #6.
This commit is contained in:
2026-10-03 21:09:36 +00:00
parent d448a4377b
commit 9b35fcef11
5 changed files with 223 additions and 158 deletions
+17 -7
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import {
ASSISTANT_MARKER,
ASSISTANT_LABEL,
STREAMING_CURSOR,
USER_MARKER,
USER_LABEL,
buildThreadMarkdown,
previewText,
quote,
@@ -46,16 +46,26 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("4");
});
it("marks each speaker with a role icon", () => {
it("labels each speaker, in order", () => {
const markdown = buildThreadMarkdown([
message({ role: "user", text: "q" }),
message({ role: "assistant", text: "a" }),
]);
expect(markdown).toContain(USER_MARKER);
expect(markdown).toContain(ASSISTANT_MARKER);
// The user marker must not leak onto the answer or vice versa.
expect(markdown.indexOf(USER_MARKER)).toBeLessThan(markdown.indexOf(ASSISTANT_MARKER));
expect(markdown).toContain(`**${USER_LABEL}**`);
expect(markdown).toContain(`**${ASSISTANT_LABEL}**`);
expect(markdown.indexOf(USER_LABEL)).toBeLessThan(markdown.indexOf(ASSISTANT_LABEL));
});
it("renders no images, because markdown cannot resolve them", () => {
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
// every candidate icon source fails. Keep the transcript text-only.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "q" }),
message({ role: "assistant", text: "a" }),
]);
expect(markdown).not.toContain("![");
});
it("keeps the conversation in order", () => {
+182 -131
View File
@@ -1,74 +1,98 @@
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api";
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat";
import { buildThreadMarkdown } from "../markdown";
import { getLamPreferences } from "../preferences";
import { listSessions, loadSession } from "../sessions";
import { buildThread } from "../thread";
import type { Session, SessionSummary, ThreadMessage } from "../types";
import { truncate } from "../utils";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { buildThread, type LiveState } from "../thread";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { formatRelativeTime } from "../utils";
/**
* The chat surface.
* The chat surface: conversations on the left, the open conversation on the right.
*
* Layout notes, because most of this is a consequence of how Vicinae renders
* lists rather than a free choice:
* Most of this is a consequence of how Vicinae renders lists rather than a free
* choice:
*
* - 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.
* It is single-line; there is no multi-line composer by design.
* - `filtering` is off. This list is a transcript, so the builtin fuzzy filter
* would hide messages as the user types their next question.
* - Rows are messages, not turns. Storage keeps a question and answer together
* because that is what gets replayed to the model; the transcript flattens
* them so each side gets its own icon and weight.
* - Rows are a fixed single line (38px in `ListItemDelegate.qml`). `title`
* renders in the foreground colour and `subtitle` in a muted one, so the
* user's line goes in the subtitle to make it recede and the answer goes in
* the title to stand out. This is the only way to vary text weight from the
* extension API.
* - Messages are oldest first and the detail panel shows the whole thread. The
* thread is shown rather than just the selected message because Vicinae
* resets list selection to the first row on every model change, and streaming
* changes the model on every flush — so the selected row is not something
* that can be relied on to stay put while an answer arrives.
* - Tab reaches the session dropdown in the search bar accessory.
* 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.
*
* 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.
*/
/** Sentinel value for the dropdown row that starts a fresh conversation. */
const NEW_SESSION_VALUE = "__new__";
const IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
export interface ChatViewProps {
/** Reopens an existing conversation instead of resuming the latest one. */
/** Opens a specific conversation instead of resuming the latest one. */
initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */
initialPrompt?: string;
}
interface StoredConversation {
summary: SessionSummary;
turns: ChatTurn[];
}
interface SessionRow {
id: string;
title: string;
preview: string;
updatedAt: number;
isActive: boolean;
/** Precomputed so a render does not rebuild every stored transcript. */
markdown: string;
}
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const preferences = useMemo(() => getLamPreferences(), []);
const chat = useChat({ preferences, initialSession });
const { replaceSession } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [ready, setReady] = useState(Boolean(initialSession));
const [stored, setStored] = useState<StoredConversation[] | undefined>(undefined);
const [resumed, setResumed] = useState(Boolean(initialSession));
const refreshSessions = useCallback(() => {
void listSessions()
.then(setSessions)
.catch(() => setSessions([]));
const activeId = chat.session.id;
/**
* 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.
*/
const reload = useCallback(() => {
void (async () => {
const summaries = await listSessions();
const conversations = await Promise.all(
summaries.map(async (summary) => {
const session = await loadSession(summary.id);
return { summary, turns: session?.turns ?? [] };
}),
);
setStored(conversations);
})();
}, []);
// Refreshed after each turn so the Tab dropdown picks up the newly saved chat.
useEffect(() => {
refreshSessions();
}, [refreshSessions, chat.session.updatedAt]);
reload();
}, [reload, chat.session.updatedAt]);
/**
* Reopening the command continues the most recent conversation rather than
* starting a fresh chat every time. Starting over is explicit, via ⌘N or the
* "New chat" row in the dropdown.
* starting a fresh chat every time. Starting over is explicit: ⌘N.
*/
useEffect(() => {
if (initialSession) return;
@@ -81,7 +105,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const session = await loadSession(latest.id);
if (!cancelled && session) replaceSession(session);
} finally {
if (!cancelled) setReady(true);
if (!cancelled) setResumed(true);
}
})();
@@ -101,7 +125,62 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
);
const threadMarkdown = useMemo(() => buildThreadMarkdown(thread), [thread]);
const threadMarkdown = useMemo(
() =>
thread.length === 0
? "**Ask Lam anything**\n\nType a question in the search bar and press Return."
: buildThreadMarkdown(thread),
[thread],
);
const rows: SessionRow[] = useMemo(() => {
const base = stored ?? [];
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)),
}));
// A conversation that has not finished a turn yet is not in the index.
if (base.some((conversation) => conversation.summary.id === activeId)) return mapped;
const active = summarizeSession(chat.session);
return [
{
id: active.id,
title: active.title,
preview: active.preview,
updatedAt: active.updatedAt,
isActive: true,
markdown: "",
},
...mapped,
];
}, [stored, activeId, chat.session]);
const openSession = useCallback(
(id: string) => {
void loadSession(id).then((session) => {
if (session) replaceSession(session);
});
},
[replaceSession],
);
const removeSession = useCallback(
(id: string) => {
void (async () => {
await deleteSession(id);
if (id === activeId) chat.newSession();
reload();
})();
},
[activeId, chat, reload],
);
const send = useCallback(
(text: string) => {
@@ -111,55 +190,47 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.ask],
);
const hasPrompt = input.trim().length > 0;
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={Keyboard.Shortcut.Common.New}
onAction={chat.newSession}
/>
);
/**
* Every row carries the same primary action, so Return always sends whatever
* is in the search bar whichever row happens to be selected. That is what
* makes the window feel like a single input box rather than a menu.
* 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
* highlighted conversation instead.
*/
const rowActions = (message: ThreadMessage) => (
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
<Action.CopyToClipboard
title={message.role === "user" ? "Copy Question" : "Copy Answer"}
content={message.text}
shortcut={Keyboard.Shortcut.Common.Copy}
const rowActions = (row: SessionRow) => {
const openAction = (
<Action
title="Open Conversation"
icon={Icon.CheckCircle}
onAction={() => openSession(row.id)}
/>
{newChatAction}
</ActionPanel>
);
);
const dropdownItems = useMemo(() => {
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
if (hasCurrent) return sessions;
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}
/>
<Action
title="Delete Conversation"
icon={Icon.Trash}
style="destructive"
onAction={() => removeSession(row.id)}
/>
</ActionPanel>
);
};
return [
{
id: chat.session.id,
title: chat.session.title,
model: chat.session.model,
createdAt: chat.session.createdAt,
updatedAt: chat.session.updatedAt,
turnCount: chat.session.turns.length,
preview: "",
},
...sessions,
];
}, [sessions, chat.session]);
if (!ready) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversation…" />;
if (!resumed || stored === undefined) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
}
return (
@@ -171,61 +242,41 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
onSearchTextChange={setInput}
navigationTitle="Lam"
searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
searchBarAccessory={
<List.Dropdown
tooltip="Switch conversation"
placeholder="New chat"
value={chat.session.id}
onChange={(value) => {
if (value === NEW_SESSION_VALUE) {
chat.newSession();
return;
}
void loadSession(value).then((session) => {
if (session) chat.replaceSession(session);
});
}}
>
<List.Dropdown.Item title="+ New chat" value={NEW_SESSION_VALUE} />
<List.Dropdown.Section title="Recent">
{dropdownItems.map((summary) => (
<List.Dropdown.Item key={summary.id} title={truncate(summary.title, 44)} value={summary.id} />
))}
</List.Dropdown.Section>
</List.Dropdown>
}
actions={
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{newChatAction}
<Action
title="New Chat"
icon={Icon.Plus}
shortcut={{ key: "n", modifiers: ["cmd"] }}
onAction={chat.newSession}
/>
</ActionPanel>
}
>
{thread.length === 0 ? (
<List.EmptyView
icon={Icon.Stars}
title="Ask Lam anything"
description="Type a question and press Return."
/>
) : (
thread.map((message) => {
const isUser = message.role === "user";
{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={message.id}
id={message.id}
icon={isUser ? Icon.Person : Icon.Stars}
// The renderer draws `title` bright and `subtitle` muted, so the
// question lives in the subtitle and the answer in the title.
title={isUser ? "" : message.preview}
subtitle={isUser ? message.preview : undefined}
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={message.isStreaming} />}
actions={rowActions(message)}
/>
);
})
)}
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)}
/>
);
})}
</List>
);
}
+12 -8
View File
@@ -15,14 +15,18 @@ import type { ChatTurn, ThreadMessage } from "./types";
export const STREAMING_CURSOR = "▍";
/**
* Role markers for the transcript.
* Speaker labels.
*
* Emoji rather than icon images: Vicinae's markdown renderer is minimal and
* there is no reliable way to inline an `icon://` reference into markdown, but
* plain text always renders.
* Plain text, not icons. Vicinae's markdown renderer draws images as
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
* 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.
*/
export const USER_MARKER = "👤";
export const ASSISTANT_MARKER = "✨";
export const USER_LABEL = "You";
export const ASSISTANT_LABEL = "Lam";
/** Prefixes every line so a multi-line message stays one continuous quote block. */
export function quote(text: string): string {
@@ -41,11 +45,11 @@ export function previewText(text: string, max: number): string {
function renderMessage(message: ThreadMessage): string {
if (message.role === "user") {
return `${USER_MARKER} **You**\n\n${quote(message.text)}`;
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
}
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
const sections = [`${ASSISTANT_MARKER} **Lam**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
const sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
if (message.error) {
sections.push("", `**Error** — ${message.error}`);
+5
View File
@@ -22,6 +22,11 @@ function newId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Builds the lightweight index record kept in the session list. */
export function summarizeSession(session: Session): SessionSummary {
return summarize(session);
}
function sessionKey(id: string): string {
return `${STORAGE_SESSION_PREFIX}${id}`;
}