feat: message-per-row transcript, session resume, drop the composer (#4)
This commit was merged in pull request #4.
This commit is contained in:
@@ -1,84 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAMING_CURSOR,
|
||||
buildTurnMarkdown,
|
||||
pendingMarkdown,
|
||||
buildThreadMarkdown,
|
||||
previewText,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||
|
||||
describe("buildTurnMarkdown", () => {
|
||||
it("renders the question as a quote block above the answer", () => {
|
||||
const markdown = buildTurnMarkdown({ prompt: "What is 2+2?", response: "4" });
|
||||
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
||||
return {
|
||||
id: "m1",
|
||||
text: "",
|
||||
preview: "",
|
||||
createdAt: 0,
|
||||
isStreaming: false,
|
||||
thinking: "",
|
||||
toolCalls: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
expect(markdown).toContain("> What is 2+2?");
|
||||
expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4"));
|
||||
describe("quote", () => {
|
||||
it("keeps a multi-line message inside one quote block", () => {
|
||||
expect(quote("line one\nline two")).toBe("> line one\n> line two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildThreadMarkdown", () => {
|
||||
it("returns nothing for an empty conversation", () => {
|
||||
expect(buildThreadMarkdown([])).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a multi-line question inside the quote block", () => {
|
||||
const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" });
|
||||
it("renders a question as a quote and an answer as body text", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "user", text: "What is 2+2?" }),
|
||||
message({ role: "assistant", text: "4" }),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("> line one\n> line two");
|
||||
expect(markdown).toContain("**You**");
|
||||
expect(markdown).toContain("> What is 2+2?");
|
||||
expect(markdown).toContain("**Lam**");
|
||||
expect(markdown).toContain("4");
|
||||
});
|
||||
|
||||
it("keeps the conversation in order", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ id: "a", role: "user", text: "first" }),
|
||||
message({ id: "b", role: "assistant", text: "second" }),
|
||||
message({ id: "c", role: "user", text: "third" }),
|
||||
]);
|
||||
|
||||
expect(markdown.indexOf("first")).toBeLessThan(markdown.indexOf("second"));
|
||||
expect(markdown.indexOf("second")).toBeLessThan(markdown.indexOf("third"));
|
||||
});
|
||||
|
||||
it("marks a streaming answer with a cursor", () => {
|
||||
const streaming = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: true });
|
||||
const finished = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: false });
|
||||
const streaming = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "partial", isStreaming: true }),
|
||||
]);
|
||||
const finished = buildThreadMarkdown([message({ role: "assistant", text: "partial" })]);
|
||||
|
||||
expect(streaming).toContain(STREAMING_CURSOR);
|
||||
expect(finished).not.toContain(STREAMING_CURSOR);
|
||||
});
|
||||
|
||||
it("shows a placeholder while an answer is still empty", () => {
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: true })).toContain("Thinking");
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: false })).toContain("No response");
|
||||
it("shows a placeholder while an answer has no text yet", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking");
|
||||
expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response");
|
||||
});
|
||||
|
||||
it("surfaces an error instead of hiding it", () => {
|
||||
const markdown = buildTurnMarkdown({ prompt: "q", response: "", error: "Ollama rejected the API key." });
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", error: "Ollama rejected the API key." }),
|
||||
]);
|
||||
expect(markdown).toContain("Ollama rejected the API key.");
|
||||
});
|
||||
|
||||
it("lists tools that were used, flagging failures", () => {
|
||||
const markdown = buildTurnMarkdown({
|
||||
prompt: "q",
|
||||
response: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
],
|
||||
});
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
role: "assistant",
|
||||
text: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(markdown).toContain("`web_search`");
|
||||
expect(markdown).toContain("`web_fetch` — failed");
|
||||
});
|
||||
|
||||
it("omits the reasoning section when there is none", () => {
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "a" })).not.toContain("Reasoning");
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "a", thinking: "hmm" })).toContain("Reasoning");
|
||||
});
|
||||
|
||||
it("includes the model and timestamp in the footer", () => {
|
||||
const markdown = buildTurnMarkdown({
|
||||
prompt: "q",
|
||||
response: "a",
|
||||
model: "gpt-oss:120b",
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
expect(markdown).toContain("`gpt-oss:120b`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingMarkdown", () => {
|
||||
it("shows the question with an activity line", () => {
|
||||
const markdown = pendingMarkdown("q", "Searching the web");
|
||||
expect(markdown).toContain("> q");
|
||||
expect(markdown).toContain("_Searching the web…_");
|
||||
});
|
||||
|
||||
it("falls back to a generic line when there is no status", () => {
|
||||
expect(pendingMarkdown("q")).toContain("_Thinking…_");
|
||||
it("only shows a reasoning section when there is reasoning", () => {
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning");
|
||||
expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain(
|
||||
"Reasoning",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +109,10 @@ describe("previewText", () => {
|
||||
it("clips with an ellipsis", () => {
|
||||
expect(previewText("a".repeat(50), 5)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("leaves short text alone", () => {
|
||||
expect(previewText("hi", 40)).toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionToPlainText", () => {
|
||||
@@ -111,7 +134,8 @@ describe("sessionToPlainText", () => {
|
||||
});
|
||||
|
||||
it("includes the failure when a turn errored", () => {
|
||||
const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]);
|
||||
expect(text).toContain("Ollama rejected the API key.");
|
||||
expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
|
||||
"Ollama rejected the API key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MESSAGE_PREVIEW_LENGTH, buildThread, type LiveState } from "../lib/thread";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
|
||||
const idle: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||
|
||||
function turn(id: string, prompt: string, response: string, overrides: Partial<ChatTurn> = {}): ChatTurn {
|
||||
return {
|
||||
id,
|
||||
prompt,
|
||||
response,
|
||||
thinking: "",
|
||||
model: "m",
|
||||
createdAt: 100,
|
||||
toolCalls: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildThread", () => {
|
||||
it("returns nothing for an empty conversation", () => {
|
||||
expect(buildThread([], idle)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flattens a turn into a user message followed by an assistant message", () => {
|
||||
const thread = buildThread([turn("t1", "hello", "hi there")], idle);
|
||||
|
||||
expect(thread.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(thread[0].text).toBe("hello");
|
||||
expect(thread[1].text).toBe("hi there");
|
||||
});
|
||||
|
||||
it("keeps messages in chronological order across turns", () => {
|
||||
const thread = buildThread(
|
||||
[turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")],
|
||||
idle,
|
||||
);
|
||||
|
||||
expect(thread.map((message) => message.text)).toEqual([
|
||||
"first question",
|
||||
"first answer",
|
||||
"second question",
|
||||
"second answer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every message a unique id", () => {
|
||||
const thread = buildThread([turn("t1", "a", "b"), turn("t2", "c", "d")], idle);
|
||||
const ids = thread.map((message) => message.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("builds one-line previews", () => {
|
||||
const thread = buildThread([turn("t1", "hello\n\nworld", "a\nlong\n\nanswer")], idle);
|
||||
|
||||
expect(thread[0].preview).toBe("hello world");
|
||||
expect(thread[1].preview).toBe("a long answer");
|
||||
});
|
||||
|
||||
it("clips a preview longer than the row can show", () => {
|
||||
const long = "x".repeat(MESSAGE_PREVIEW_LENGTH + 50);
|
||||
const thread = buildThread([turn("t1", long, long)], idle);
|
||||
|
||||
expect(thread[0].preview).toHaveLength(MESSAGE_PREVIEW_LENGTH);
|
||||
});
|
||||
|
||||
it("streams the newest turn only", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "streaming now", thinking: "hmm" };
|
||||
const thread = buildThread([turn("t1", "old", "old answer"), turn("t2", "new", "")], live);
|
||||
|
||||
// The older turn stays put...
|
||||
expect(thread[1].isStreaming).toBe(false);
|
||||
expect(thread[1].text).toBe("old answer");
|
||||
|
||||
// ...and the newest assistant message reads from the live buffer.
|
||||
expect(thread[3].isStreaming).toBe(true);
|
||||
expect(thread[3].text).toBe("streaming now");
|
||||
expect(thread[3].thinking).toBe("hmm");
|
||||
|
||||
// The user's line is never "streaming".
|
||||
expect(thread[2].isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the activity line while an answer has no text yet", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" };
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Searching the web");
|
||||
});
|
||||
|
||||
it("falls back to a generic label while thinking", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "" };
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Thinking…");
|
||||
});
|
||||
|
||||
it("previews a stored error when a finished turn has no answer", () => {
|
||||
const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle);
|
||||
|
||||
expect(thread[1].preview).toBe("Request failed");
|
||||
expect(thread[1].error).toBe("Request failed");
|
||||
});
|
||||
|
||||
it("carries tool calls onto the assistant message", () => {
|
||||
const withTools = turn("t1", "q", "a", {
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
|
||||
});
|
||||
const thread = buildThread([withTools], idle);
|
||||
|
||||
expect(thread[0].toolCalls).toEqual([]);
|
||||
expect(thread[1].toolCalls).toHaveLength(1);
|
||||
expect(thread[1].toolCalls[0].name).toBe("web_search");
|
||||
});
|
||||
});
|
||||
+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