feat: message-per-row transcript, session resume, drop the composer
Three changes from testing the launcher: 1. Remove the multi-line composer. It rendered as a small box floating in a large empty form. That is structural, not a styling miss: Vicinae always renders a Form.TextArea inside FormField, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer. With no way to reclaim that width, and no clean full-width variant available, prompts stay single line. 2. Resume the most recent conversation when the command opens. Every launch used to start a fresh chat, so there was no way to continue anything. Starting over is now explicit: cmd+N, or "New chat" in the dropdown. 3. One row per message instead of one row per turn. Storage keeps a question and its answer together because that is what gets replayed to the model; thread.ts flattens that so each side gets its own icon and weight. Rows are a fixed single line and the renderer draws title bright and subtitle muted, so the question goes in the subtitle and the answer in the title -- that is what dims the user and brightens Lam. Icons are Person and Stars. The detail panel now shows the whole thread rather than the selected message. Vicinae resets list selection to the first row on every model change and streaming changes the model on every flush, so the selected row cannot be relied on to stay put while an answer arrives. Verified by running it: the existing saved session resumed and rendered as four rows (two dim questions, two bright answers) with the thread in the detail panel. typecheck, eslint, 107 tests and vici build all pass.
This commit is contained in:
+105
-112
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user