feat: message-per-row transcript, session resume, drop the composer (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-10-03 20:59:01 +00:00
parent af0c75a65d
commit 31eb688455
8 changed files with 444 additions and 300 deletions
+105 -112
View File
@@ -1,63 +1,58 @@
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat";
import { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown";
import { buildThreadMarkdown } from "../markdown";
import { getLamPreferences } from "../preferences";
import { listSessions, loadSession } from "../sessions";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { formatRelativeTime, truncate } from "../utils";
import { ComposeForm } from "./ComposeForm";
import { buildThread } from "../thread";
import type { Session, SessionSummary, ThreadMessage } from "../types";
import { truncate } from "../utils";
/**
* The chat surface.
*
* Layout notes, because they are consequences of how Vicinae renders lists
* rather than free choices:
* Layout notes, because most of this is a consequence of how Vicinae renders
* lists rather than a free choice:
*
* - The search bar doubles as the composer. It is the only always-focused text
* input in a List, which is what makes "hit a hotkey and just start typing"
* work. It is single-line, hence the separate ⌘↩ composer.
* - `filtering` is off. This list is a transcript, not a set of search results,
* so the builtin fuzzy filter would hide turns as the user types a question.
* - Turns render newest first. The answer the user just asked for should be the
* thing nearest the search bar, not something they have to scroll to.
* - Tab reaches the session dropdown in the search bar accessory, which is how
* "Tab to move through previous conversations" is wired.
* - 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.
*/
/** Sentinel value for the dropdown row that starts a fresh conversation. */
const NEW_SESSION_VALUE = "__new__";
export interface ChatViewProps {
/** Reopens an existing conversation instead of starting a new one. */
/** Reopens an existing conversation instead of resuming the latest one. */
initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */
initialPrompt?: string;
}
function detailMarkdown(turn: ChatTurn, live: boolean, streamed: string, streamedThinking: string, status?: string): string {
if (live && !streamed && !turn.response) {
return pendingMarkdown(turn.prompt, status);
}
return buildTurnMarkdown({
prompt: turn.prompt,
response: live ? streamed : turn.response,
thinking: live ? streamedThinking : turn.thinking,
toolCalls: turn.toolCalls,
error: live ? undefined : turn.error,
model: turn.model,
createdAt: turn.createdAt,
isStreaming: live,
});
}
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 refreshSessions = useCallback(() => {
void listSessions()
@@ -70,6 +65,44 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
refreshSessions();
}, [refreshSessions, 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.
*/
useEffect(() => {
if (initialSession) return;
let cancelled = false;
void (async () => {
try {
const [latest] = await listSessions();
if (!latest) return;
const session = await loadSession(latest.id);
if (!cancelled && session) replaceSession(session);
} finally {
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [initialSession, replaceSession]);
const thread = useMemo(
() =>
buildThread(chat.session.turns, {
isStreaming: chat.isStreaming,
response: chat.streamingResponse,
thinking: chat.streamingThinking,
status: chat.status,
}),
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
);
const threadMarkdown = useMemo(() => buildThreadMarkdown(thread), [thread]);
const send = useCallback(
(text: string) => {
chat.ask(text);
@@ -78,22 +111,8 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.ask],
);
const stopAction = (
<Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />
);
const sendAction = (
<Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />
);
const composeAction = (
<Action.Push
title="Compose Longer Message"
icon={Icon.Text}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
target={<ComposeForm initialValue={input} onSubmit={send} />}
/>
);
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
@@ -105,45 +124,43 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
);
/**
* Every row carries the same primary action. Enter therefore always sends the
* current search text, whichever row happens to be selected, which is what
* makes the whole window feel like a single input box.
* 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.
*/
const rowActions = (turn: ChatTurn) => (
const rowActions = (message: ThreadMessage) => (
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
<Action.CopyToClipboard
title="Copy Answer"
content={turn.response}
title={message.role === "user" ? "Copy Question" : "Copy Answer"}
content={message.text}
shortcut={Keyboard.Shortcut.Common.Copy}
/>
<Action.CopyToClipboard title="Copy Question" content={turn.prompt} />
{newChatAction}
</ActionPanel>
);
const dropdownItems = useMemo(() => {
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
const withCurrent: SessionSummary[] = hasCurrent
? sessions
: [
{
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,
];
if (hasCurrent) return sessions;
return withCurrent;
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]);
const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]);
if (!ready) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversation…" />;
}
return (
<List
@@ -153,9 +170,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
searchText={input}
onSearchTextChange={setInput}
navigationTitle="Lam"
searchBarPlaceholder={
chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"
}
searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
searchBarAccessory={
<List.Dropdown
tooltip="Switch conversation"
@@ -174,11 +189,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
<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.Item key={summary.id} title={truncate(summary.title, 44)} value={summary.id} />
))}
</List.Dropdown.Section>
</List.Dropdown>
@@ -186,49 +197,31 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
actions={
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
{newChatAction}
</ActionPanel>
}
>
{orderedTurns.length === 0 ? (
{thread.length === 0 ? (
<List.EmptyView
icon={Icon.Stars}
title="Ask Lam anything"
description="Type a question and press Return. Use ⌘↩ for a longer, multi-line message."
description="Type a question and press Return."
/>
) : (
orderedTurns.map((turn, index) => {
const live = index === 0 && chat.isStreaming;
const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }];
if (turn.toolCalls.length > 0) {
accessories.unshift({ tag: `${turn.toolCalls.length} tools` });
}
thread.map((message) => {
const isUser = message.role === "user";
return (
<List.Item
key={turn.id}
id={turn.id}
icon={live ? Icon.ArrowClockwise : Icon.SpeechBubble}
title={truncate(turn.prompt, 72)}
subtitle={
live ? (chat.status ?? "Thinking…") : previewText(turn.response || turn.error || "", 90)
}
accessories={accessories}
detail={
<List.Item.Detail
markdown={detailMarkdown(
turn,
live,
chat.streamingResponse,
chat.streamingThinking,
chat.status,
)}
isLoading={live}
/>
}
actions={rowActions(turn)}
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)}
/>
);
})
-76
View File
@@ -1,76 +0,0 @@
import { Action, ActionPanel, Form, Icon, useNavigation } from "@vicinae/api";
import { useState } from "react";
/**
* The multi-line composer.
*
* This view exists because the launcher's search bar is single-line and there
* is no API to change that. Pressing ⌘↩ in the chat view pushes this view, where
* a real multi-line text area gives the behaviour people expect from a chat box:
* Return inserts a newline, ⌘↩ sends.
*
* It is deliberately stripped down to a single bare editor:
*
* - no field label, so nothing competes with the text itself
* - no description block, because the action bar already renders "Send ⌘↩"
* next to the button, which documents the shortcut without a paragraph
* - the placeholder is one short line rather than an instructions sentence
*
* ## The width limit
*
* A `Form.TextArea` cannot be made truly full-width from the extension API.
* Vicinae always renders it inside its `FormField` component
* (`ExtensionFormView.qml` → `textareaFieldComp`), and `FormField.qml` always
* lays out as three columns with proportional widths 2 / 5 / 2 — a label
* gutter, the editor, and an empty right spacer. That leaves roughly five
* ninths of the width for the editor, and the spacer stays even when there is
* no label. None of it is reachable from the extension side.
*
* So the only lever here is removing clutter, not reclaiming width. If Vicinae
* ever exposes a label-less or full-width field variant, this is the file to
* simplify further.
*/
export interface ComposeFormProps {
initialValue: string;
onSubmit: (text: string) => void;
}
export function ComposeForm({ initialValue, onSubmit }: ComposeFormProps) {
const { pop } = useNavigation();
const [error, setError] = useState<string | undefined>(undefined);
return (
<Form
navigationTitle="Compose"
actions={
<ActionPanel>
<Action.SubmitForm
title="Send"
icon={Icon.SpeechBubble}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
onSubmit={(values) => {
const text = String(values.prompt ?? "").trim();
if (!text) {
setError("Write something first.");
return;
}
// Pop before handing off so the chat view is already visible when
// streaming starts, rather than showing a stale composer.
pop();
onSubmit(text);
}}
/>
<Action title="Cancel" onAction={pop} />
</ActionPanel>
}
>
<Form.TextArea
id="prompt"
placeholder="Ask Lam anything…"
defaultValue={initialValue}
error={error}
autoFocus
/>
</Form>
);
}
+45 -53
View File
@@ -1,80 +1,72 @@
import type { ChatTurn } from "./types";
import type { ChatTurn, ThreadMessage } from "./types";
/**
* Renders a turn as markdown for the detail panel.
* Markdown rendering for the detail panel.
*
* Kept as a pure function so the transcript layout can be tested without
* mounting a component, and so the same renderer can serve both a finished
* turn and one that is still streaming.
* Kept as pure functions so the transcript layout can be tested without
* mounting a component.
*
* 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.
*/
export const STREAMING_CURSOR = "▍";
export interface TurnMarkdownInput {
prompt: string;
response: string;
thinking?: string;
toolCalls?: ChatTurn["toolCalls"];
error?: string;
model?: string;
createdAt?: number;
isStreaming?: boolean;
}
/** Prefixes every line so a multi-line prompt renders as one continuous quote block. */
function quote(text: string): string {
/** Prefixes every line so a multi-line message stays one continuous quote block. */
export function quote(text: string): string {
return text
.split("\n")
.map((line) => `> ${line}`.trimEnd())
.join("\n");
}
export function buildTurnMarkdown(input: TurnMarkdownInput): string {
const sections: string[] = [];
/** 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()}…`;
}
sections.push(quote(input.prompt));
sections.push("");
const body = input.response.trim() || (input.isStreaming ? "_Thinking…_" : "_No response._");
sections.push(input.isStreaming ? `${body} ${STREAMING_CURSOR}` : body);
if (input.error) {
sections.push("", `**Error** — ${input.error}`);
function renderMessage(message: ThreadMessage): string {
if (message.role === "user") {
return `**You**\n\n${quote(message.text)}`;
}
const toolCalls = input.toolCalls ?? [];
if (toolCalls.length > 0) {
const lines = toolCalls.map((call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`);
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
const sections = ["**Lam**", "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
if (message.error) {
sections.push("", `**Error** — ${message.error}`);
}
if (message.toolCalls.length > 0) {
const lines = message.toolCalls.map(
(call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`,
);
sections.push("", "**Tools used**", ...lines);
}
if (input.thinking && input.thinking.trim().length > 0) {
sections.push("", "---", "", "**Reasoning**", "", quote(input.thinking.trim()));
}
const footer: string[] = [];
if (input.model) footer.push(`\`${input.model}\``);
if (input.createdAt) footer.push(new Date(input.createdAt).toLocaleString());
if (footer.length > 0) {
sections.push("", "---", "", footer.join(" · "));
if (message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
}
return sections.join("\n");
}
/** The placeholder shown while the first tokens are still in flight. */
export function pendingMarkdown(prompt: string, status?: string): string {
const lines = [quote(prompt), ""];
lines.push(status ? `_${status}…_` : "_Thinking…_");
return lines.join("\n");
}
/** A short one-line preview of an answer, for a collapsed 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()}…`;
/**
* Renders the whole conversation.
*
* 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
* 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.
*/
export function buildThreadMarkdown(messages: ThreadMessage[]): string {
if (messages.length === 0) return "";
return messages.map(renderMessage).join("\n\n---\n\n");
}
/**
+72
View File
@@ -0,0 +1,72 @@
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.
*/
/** 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". */
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: [],
});
// Only the newest turn can be mid-flight.
const isLive = index === lastIndex && 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,
});
});
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";
}
+20
View File
@@ -85,3 +85,23 @@ export interface ModelInfo {
name: string;
parameterSize?: string;
}
/**
* One rendered line of the conversation.
*
* A turn is stored as a question/answer pair, but the transcript is displayed as
* a flat sequence of messages so each side can carry its own icon and weight.
*/
export interface ThreadMessage {
id: string;
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;
}