feat: conversations on the left, the transcript on the right
The transcript was duplicated across both panes, which was the actual complaint. The left column is now the list of conversations and the right pane is the transcript of the selected one, so the panes show different things. - the open conversation is marked with a check icon and an "Open" tag - each row carries its own transcript as its detail, so moving the selection previews that conversation - conversations are ordered most-recently-updated first, which keeps the one being chatted in at row zero, where Vicinae's selection reset lands - Return now adapts: with text in the search bar it sends, with an empty bar it opens the highlighted conversation (sending an empty prompt was a no-op anyway) - the search bar accessory dropdown is gone. The left column is the conversation switcher now, so the dropdown only duplicated it. Speaker icons in the transcript are not possible, so the labels are plain text. MdImage renders images block-level, centred, and capped at 200px tall, and all three candidate sources were tried in a live probe -- icon://person, a raw icon slug, and a file:// asset path -- and each one fell back to rendering its alt text as a caption. markdown.ts records the reason so nobody retries it blind. Verified on screen: one conversation row with an Open tag and a check icon, the full transcript on the right, and the action bar reading "Open Conversation" while the search bar is empty. typecheck, eslint, 109 tests and vici build all pass.
This commit is contained in:
@@ -54,12 +54,11 @@ To change the model later, either edit the **Model** preference directly, or run
|
||||
| Keys | Action |
|
||||
| --- | --- |
|
||||
| `↩` | Send the message in the search bar |
|
||||
| `Tab` | Jump to the conversation switcher in the search bar |
|
||||
| `↩` with an empty bar | Open the highlighted conversation |
|
||||
| `⌘ N` | New chat |
|
||||
| `⌘ ⇧ C` | Copy the highlighted message |
|
||||
| `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) |
|
||||
|
||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed — see [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher.
|
||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed — see [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
|
||||
|
||||
### Suggested global shortcuts
|
||||
|
||||
@@ -140,19 +139,15 @@ src/
|
||||
|
||||
A few decisions worth knowing about before you change things:
|
||||
|
||||
**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is single-line and there is no API to change that. A multi-line `Form.TextArea` composer was built and then removed: Vicinae always renders it inside `FormField`, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer, so it shows up as a small box floating in a large empty form. `filtering` is off, because this list is a transcript — the builtin fuzzy filter would hide messages as you type a question.
|
||||
**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is single-line and there is no API to change that. A multi-line `Form.TextArea` composer was built and then removed: Vicinae always renders it inside `FormField`, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer, so it shows up as a small box floating in a large empty form. `filtering` is off, because the left column is a conversation switcher — the builtin fuzzy filter would hide rows as you type a question.
|
||||
|
||||
**Every transcript row shares one primary action**, so `↩` always sends whatever is in the search bar regardless of which row is selected. That is what makes the window feel like a single input box rather than a menu.
|
||||
**The two panes show different things on purpose.** Left is the list of conversations; right is the transcript of one. Both are required rather than a layout preference: `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`, `Grid`, `Form` and `Dropdown`), 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`.
|
||||
|
||||
**Rows are messages, not turns.** Storage keeps a question and its answer together because that is what gets replayed to the model. `thread.ts` flattens that into one message per row so each side can carry its own icon and weight.
|
||||
**Return adapts to the search bar.** With text in it, `↩` sends. Empty — where sending would be a no-op anyway — `↩` opens the highlighted conversation. Every row carries that conversation's transcript as its detail, so moving the selection previews it.
|
||||
|
||||
**The user's line is dimmed by putting it in `subtitle`.** List rows are a fixed single line, and the renderer draws `title` in the foreground colour and `subtitle` in a muted one. That is the only lever the extension API offers for varying text weight, so questions go in the subtitle and answers in the title.
|
||||
**Conversations are ordered most-recently-updated first.** Vicinae resets list selection to the first row on every model change (`GenericListView.selectFirstOnReset`), and streaming changes the model on every flush. Ordering by recency keeps the conversation being chatted in at row zero, which is where the selection lands.
|
||||
|
||||
**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.
|
||||
**The transcript is text-only.** Speaker labels are plain `You` / `Lam`, with the question dimmed as a blockquote and the answer as body text. Icons are not possible there: `MdImage` renders images block-level, centred, and capped at 200px tall, and every candidate source was tried — `icon://person`, a raw icon slug, and a `file://` asset path — and each fell back to rendering its alt text as a caption.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MARKER,
|
||||
ASSISTANT_LABEL,
|
||||
STREAMING_CURSOR,
|
||||
USER_MARKER,
|
||||
USER_LABEL,
|
||||
buildThreadMarkdown,
|
||||
previewText,
|
||||
quote,
|
||||
@@ -46,16 +46,26 @@ describe("buildThreadMarkdown", () => {
|
||||
expect(markdown).toContain("4");
|
||||
});
|
||||
|
||||
it("marks each speaker with a role icon", () => {
|
||||
it("labels each speaker, in order", () => {
|
||||
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));
|
||||
expect(markdown).toContain(`**${USER_LABEL}**`);
|
||||
expect(markdown).toContain(`**${ASSISTANT_LABEL}**`);
|
||||
expect(markdown.indexOf(USER_LABEL)).toBeLessThan(markdown.indexOf(ASSISTANT_LABEL));
|
||||
});
|
||||
|
||||
it("renders no images, because markdown cannot resolve them", () => {
|
||||
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
|
||||
// every candidate icon source fails. Keep the transcript text-only.
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "user", text: "q" }),
|
||||
message({ role: "assistant", text: "a" }),
|
||||
]);
|
||||
|
||||
expect(markdown).not.toContain("![");
|
||||
});
|
||||
|
||||
it("keeps the conversation in order", () => {
|
||||
|
||||
+174
-123
@@ -1,74 +1,98 @@
|
||||
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api";
|
||||
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { buildThreadMarkdown } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import { listSessions, loadSession } from "../sessions";
|
||||
import { buildThread } from "../thread";
|
||||
import type { Session, SessionSummary, ThreadMessage } from "../types";
|
||||
import { truncate } from "../utils";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { buildThread, type LiveState } from "../thread";
|
||||
import type { ChatTurn, Session, SessionSummary } from "../types";
|
||||
import { formatRelativeTime } from "../utils";
|
||||
|
||||
/**
|
||||
* The chat surface.
|
||||
* The chat surface: conversations on the left, the open conversation on the right.
|
||||
*
|
||||
* Layout notes, because most of this is a consequence of how Vicinae renders
|
||||
* lists rather than a free choice:
|
||||
* Most of this is a consequence of how Vicinae renders lists rather than a free
|
||||
* choice:
|
||||
*
|
||||
* - 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.
|
||||
* a `List`, which is what makes "hit a hotkey, then just start typing" work.
|
||||
* - `filtering` is off. This list is a conversation switcher, so the builtin
|
||||
* fuzzy filter would hide rows as the user types their question.
|
||||
* - The left column lists conversations; the right pane is the transcript. The
|
||||
* two panes are not redundant because they show different things.
|
||||
* - Every row carries that conversation's transcript as its detail, so moving
|
||||
* the selection previews a conversation.
|
||||
* - `Detail` accepts no typed input — `onSearchTextChange` exists only on
|
||||
* `List`, `Grid`, `Form` and `Dropdown` — so the left column cannot be
|
||||
* dropped: it *is* the text box.
|
||||
*
|
||||
* Ordering matters here. Vicinae resets list selection to the first row
|
||||
* whenever the model changes (`GenericListView.selectFirstOnReset`), and
|
||||
* streaming changes the model on every flush. Sessions are therefore ordered by
|
||||
* most recently updated, which keeps the conversation being chatted in at row
|
||||
* zero where the selection lands.
|
||||
*/
|
||||
|
||||
/** Sentinel value for the dropdown row that starts a fresh conversation. */
|
||||
const NEW_SESSION_VALUE = "__new__";
|
||||
const IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||
|
||||
export interface ChatViewProps {
|
||||
/** Reopens an existing conversation instead of resuming the latest one. */
|
||||
/** Opens a specific conversation instead of resuming the latest one. */
|
||||
initialSession?: Session;
|
||||
/** Text handed over by a deeplink or fallback command. */
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
interface StoredConversation {
|
||||
summary: SessionSummary;
|
||||
turns: ChatTurn[];
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
updatedAt: number;
|
||||
isActive: boolean;
|
||||
/** Precomputed so a render does not rebuild every stored transcript. */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
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 [stored, setStored] = useState<StoredConversation[] | undefined>(undefined);
|
||||
const [resumed, setResumed] = useState(Boolean(initialSession));
|
||||
|
||||
const refreshSessions = useCallback(() => {
|
||||
void listSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => setSessions([]));
|
||||
const activeId = chat.session.id;
|
||||
|
||||
/**
|
||||
* Loads every conversation in full so each row can carry its own transcript
|
||||
* as a detail. Only the index is stored separately, so this reads the bodies
|
||||
* once on mount and after each turn rather than per render.
|
||||
*/
|
||||
const reload = useCallback(() => {
|
||||
void (async () => {
|
||||
const summaries = await listSessions();
|
||||
const conversations = await Promise.all(
|
||||
summaries.map(async (summary) => {
|
||||
const session = await loadSession(summary.id);
|
||||
return { summary, turns: session?.turns ?? [] };
|
||||
}),
|
||||
);
|
||||
setStored(conversations);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Refreshed after each turn so the Tab dropdown picks up the newly saved chat.
|
||||
useEffect(() => {
|
||||
refreshSessions();
|
||||
}, [refreshSessions, chat.session.updatedAt]);
|
||||
reload();
|
||||
}, [reload, 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.
|
||||
* starting a fresh chat every time. Starting over is explicit: ⌘N.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialSession) return;
|
||||
@@ -81,7 +105,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
const session = await loadSession(latest.id);
|
||||
if (!cancelled && session) replaceSession(session);
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
if (!cancelled) setResumed(true);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -101,7 +125,62 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
|
||||
);
|
||||
|
||||
const threadMarkdown = useMemo(() => buildThreadMarkdown(thread), [thread]);
|
||||
const threadMarkdown = useMemo(
|
||||
() =>
|
||||
thread.length === 0
|
||||
? "**Ask Lam anything**\n\nType a question in the search bar and press Return."
|
||||
: buildThreadMarkdown(thread),
|
||||
[thread],
|
||||
);
|
||||
|
||||
const rows: SessionRow[] = useMemo(() => {
|
||||
const base = stored ?? [];
|
||||
|
||||
const mapped: SessionRow[] = base.map((conversation) => ({
|
||||
id: conversation.summary.id,
|
||||
title: conversation.summary.title,
|
||||
preview: conversation.summary.preview,
|
||||
updatedAt: conversation.summary.updatedAt,
|
||||
isActive: conversation.summary.id === activeId,
|
||||
markdown: buildThreadMarkdown(buildThread(conversation.turns, IDLE)),
|
||||
}));
|
||||
|
||||
// A conversation that has not finished a turn yet is not in the index.
|
||||
if (base.some((conversation) => conversation.summary.id === activeId)) return mapped;
|
||||
|
||||
const active = summarizeSession(chat.session);
|
||||
return [
|
||||
{
|
||||
id: active.id,
|
||||
title: active.title,
|
||||
preview: active.preview,
|
||||
updatedAt: active.updatedAt,
|
||||
isActive: true,
|
||||
markdown: "",
|
||||
},
|
||||
...mapped,
|
||||
];
|
||||
}, [stored, activeId, chat.session]);
|
||||
|
||||
const openSession = useCallback(
|
||||
(id: string) => {
|
||||
void loadSession(id).then((session) => {
|
||||
if (session) replaceSession(session);
|
||||
});
|
||||
},
|
||||
[replaceSession],
|
||||
);
|
||||
|
||||
const removeSession = useCallback(
|
||||
(id: string) => {
|
||||
void (async () => {
|
||||
await deleteSession(id);
|
||||
if (id === activeId) chat.newSession();
|
||||
reload();
|
||||
})();
|
||||
},
|
||||
[activeId, chat, reload],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
@@ -111,55 +190,47 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
[chat.ask],
|
||||
);
|
||||
|
||||
const hasPrompt = input.trim().length > 0;
|
||||
|
||||
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 = (
|
||||
/**
|
||||
* The primary action adapts to the search bar. With text in it, Return sends;
|
||||
* with it empty — where sending would be a no-op anyway — Return opens the
|
||||
* highlighted conversation instead.
|
||||
*/
|
||||
const rowActions = (row: SessionRow) => {
|
||||
const openAction = (
|
||||
<Action
|
||||
title="Open Conversation"
|
||||
icon={Icon.CheckCircle}
|
||||
onAction={() => openSession(row.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
|
||||
{hasPrompt ? openAction : null}
|
||||
<Action
|
||||
title="New Chat"
|
||||
icon={Icon.Plus}
|
||||
shortcut={Keyboard.Shortcut.Common.New}
|
||||
shortcut={{ key: "n", modifiers: ["cmd"] }}
|
||||
onAction={chat.newSession}
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 = (message: ThreadMessage) => (
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : sendAction}
|
||||
<Action.CopyToClipboard
|
||||
title={message.role === "user" ? "Copy Question" : "Copy Answer"}
|
||||
content={message.text}
|
||||
shortcut={Keyboard.Shortcut.Common.Copy}
|
||||
<Action
|
||||
title="Delete Conversation"
|
||||
icon={Icon.Trash}
|
||||
style="destructive"
|
||||
onAction={() => removeSession(row.id)}
|
||||
/>
|
||||
{newChatAction}
|
||||
</ActionPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
|
||||
if (hasCurrent) return sessions;
|
||||
|
||||
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]);
|
||||
|
||||
if (!ready) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversation…" />;
|
||||
if (!resumed || stored === undefined) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -171,61 +242,41 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
onSearchTextChange={setInput}
|
||||
navigationTitle="Lam"
|
||||
searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
|
||||
searchBarAccessory={
|
||||
<List.Dropdown
|
||||
tooltip="Switch conversation"
|
||||
placeholder="New chat"
|
||||
value={chat.session.id}
|
||||
onChange={(value) => {
|
||||
if (value === NEW_SESSION_VALUE) {
|
||||
chat.newSession();
|
||||
return;
|
||||
}
|
||||
void loadSession(value).then((session) => {
|
||||
if (session) chat.replaceSession(session);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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.Section>
|
||||
</List.Dropdown>
|
||||
}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : sendAction}
|
||||
{newChatAction}
|
||||
<Action
|
||||
title="New Chat"
|
||||
icon={Icon.Plus}
|
||||
shortcut={{ key: "n", modifiers: ["cmd"] }}
|
||||
onAction={chat.newSession}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
{thread.length === 0 ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.Stars}
|
||||
title="Ask Lam anything"
|
||||
description="Type a question and press Return."
|
||||
/>
|
||||
) : (
|
||||
thread.map((message) => {
|
||||
const isUser = message.role === "user";
|
||||
{rows.map((row) => {
|
||||
const accessories: List.Item.Accessory[] = [];
|
||||
if (row.isActive) accessories.push({ tag: "Open" });
|
||||
accessories.push({ text: formatRelativeTime(row.updatedAt) });
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
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)}
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
icon={row.isActive ? Icon.CheckCircle : Icon.SpeechBubble}
|
||||
title={row.title}
|
||||
subtitle={row.preview}
|
||||
accessories={accessories}
|
||||
detail={
|
||||
<List.Item.Detail
|
||||
markdown={row.isActive ? threadMarkdown : row.markdown}
|
||||
isLoading={row.isActive && chat.isStreaming}
|
||||
/>
|
||||
}
|
||||
actions={rowActions(row)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-8
@@ -15,14 +15,18 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
||||
export const STREAMING_CURSOR = "▍";
|
||||
|
||||
/**
|
||||
* Role markers for the transcript.
|
||||
* Speaker labels.
|
||||
*
|
||||
* 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.
|
||||
* Plain text, not icons. Vicinae's markdown renderer draws images as
|
||||
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
|
||||
* of the candidate sources resolve inside markdown — `icon://person`, a raw
|
||||
* icon slug, and a `file://` asset path were all tried and each one fell back
|
||||
* to rendering the alt text as a caption. An icon therefore cannot sit inline
|
||||
* beside a message the way a text label can. The dim/bright styling carries
|
||||
* most of the distinction regardless.
|
||||
*/
|
||||
export const USER_MARKER = "👤";
|
||||
export const ASSISTANT_MARKER = "✨";
|
||||
export const USER_LABEL = "You";
|
||||
export const ASSISTANT_LABEL = "Lam";
|
||||
|
||||
/** Prefixes every line so a multi-line message stays one continuous quote block. */
|
||||
export function quote(text: string): string {
|
||||
@@ -41,11 +45,11 @@ export function previewText(text: string, max: number): string {
|
||||
|
||||
function renderMessage(message: ThreadMessage): string {
|
||||
if (message.role === "user") {
|
||||
return `${USER_MARKER} **You**\n\n${quote(message.text)}`;
|
||||
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
|
||||
}
|
||||
|
||||
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
|
||||
const sections = [`${ASSISTANT_MARKER} **Lam**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
const sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
|
||||
if (message.error) {
|
||||
sections.push("", `**Error** — ${message.error}`);
|
||||
|
||||
@@ -22,6 +22,11 @@ function newId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Builds the lightweight index record kept in the session list. */
|
||||
export function summarizeSession(session: Session): SessionSummary {
|
||||
return summarize(session);
|
||||
}
|
||||
|
||||
function sessionKey(id: string): string {
|
||||
return `${STORAGE_SESSION_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user