feat: mark each speaker with a role icon in the transcript #5

Merged
adrianbonpin merged 1 commits from feat/transcript-icons into prod 2026-10-03 21:03:27 +00:00
3 changed files with 30 additions and 2 deletions
+4
View File
@@ -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. **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. **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. **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.
+14
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
ASSISTANT_MARKER,
STREAMING_CURSOR, STREAMING_CURSOR,
USER_MARKER,
buildThreadMarkdown, buildThreadMarkdown,
previewText, previewText,
quote, quote,
@@ -44,6 +46,18 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("4"); 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", () => { it("keeps the conversation in order", () => {
const markdown = buildThreadMarkdown([ const markdown = buildThreadMarkdown([
message({ id: "a", role: "user", text: "first" }), message({ id: "a", role: "user", text: "first" }),
+12 -2
View File
@@ -14,6 +14,16 @@ import type { ChatTurn, ThreadMessage } from "./types";
export const STREAMING_CURSOR = "▍"; 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. */ /** Prefixes every line so a multi-line message stays one continuous quote block. */
export function quote(text: string): string { export function quote(text: string): string {
return text return text
@@ -31,11 +41,11 @@ export function previewText(text: string, max: number): string {
function renderMessage(message: ThreadMessage): string { function renderMessage(message: ThreadMessage): string {
if (message.role === "user") { 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 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) { if (message.error) {
sections.push("", `**Error** — ${message.error}`); sections.push("", `**Error** — ${message.error}`);