feat: mark each speaker with a role icon in the transcript
Nothing in the transcript said who was speaking. Each message is now prefixed with a role marker: person for the user, sparkle for Lam. Emoji rather than icon images, because Vicinae's markdown renderer is minimal and there is no reliable way to inline an `icon://` reference into markdown, whereas plain text always renders. Also records in the README why the transcript needs both panes, since that came up: Detail accepts no typed input, so a detail-only chat command would have nothing to type into, and list rows are a fixed single line, so full text cannot live in the list. Input needs a List, full text needs a Detail. Verified on screen that both markers render.
This commit is contained in:
@@ -150,6 +150,10 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
**Messages render oldest first, and the detail panel shows the whole thread.** 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. Showing the thread keeps the live answer on screen regardless.
|
||||
|
||||
**Both panes are required, not a layout preference.** `Detail` accepts no typed input — `onSearchTextChange` exists only on `List`, `Grid`, `Form` and `Dropdown` — so a detail-only chat command would have nothing to type into. And list rows are a fixed single line, so full message text cannot render in the list. Input needs a `List`; full text needs a `Detail`. Dropping either pane breaks the feature.
|
||||
|
||||
**Speakers are marked with emoji, not icon images.** `👤` and `✨` prefix each message in the transcript. Vicinae's markdown renderer is minimal and there is no reliable way to inline an `icon://` reference into markdown, whereas plain text always renders.
|
||||
|
||||
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
|
||||
|
||||
**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MARKER,
|
||||
STREAMING_CURSOR,
|
||||
USER_MARKER,
|
||||
buildThreadMarkdown,
|
||||
previewText,
|
||||
quote,
|
||||
@@ -44,6 +46,18 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(markdown).toContain("4");
|
||||
});
|
||||
|
||||
it("marks each speaker with a role icon", () => {
|
||||
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));
|
||||
});
|
||||
|
||||
it("keeps the conversation in order", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ id: "a", role: "user", text: "first" }),
|
||||
|
||||
+12
-2
@@ -14,6 +14,16 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
||||
|
||||
export const STREAMING_CURSOR = "▍";
|
||||
|
||||
/**
|
||||
* Role markers for the transcript.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const USER_MARKER = "👤";
|
||||
export const ASSISTANT_MARKER = "✨";
|
||||
|
||||
/** Prefixes every line so a multi-line message stays one continuous quote block. */
|
||||
export function quote(text: string): string {
|
||||
return text
|
||||
@@ -31,11 +41,11 @@ export function previewText(text: string, max: number): string {
|
||||
|
||||
function renderMessage(message: ThreadMessage): string {
|
||||
if (message.role === "user") {
|
||||
return `**You**\n\n${quote(message.text)}`;
|
||||
return `${USER_MARKER} **You**\n\n${quote(message.text)}`;
|
||||
}
|
||||
|
||||
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
|
||||
const sections = ["**Lam**", "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
const sections = [`${ASSISTANT_MARKER} **Lam**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
|
||||
if (message.error) {
|
||||
sections.push("", `**Error** — ${message.error}`);
|
||||
|
||||
Reference in New Issue
Block a user