feat: conversations on the left, the transcript on the right #6
@@ -54,12 +54,11 @@ To change the model later, either edit the **Model** preference directly, or run
|
|||||||
| Keys | Action |
|
| Keys | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `↩` | Send the message in the search bar |
|
| `↩` | 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 |
|
| `⌘ N` | New chat |
|
||||||
| `⌘ ⇧ C` | Copy the highlighted message |
|
|
||||||
| `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) |
|
| `⌘ ⌫` | 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
|
### Suggested global shortcuts
|
||||||
|
|
||||||
@@ -140,19 +139,15 @@ src/
|
|||||||
|
|
||||||
A few decisions worth knowing about before you change things:
|
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.
|
**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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
ASSISTANT_MARKER,
|
ASSISTANT_LABEL,
|
||||||
STREAMING_CURSOR,
|
STREAMING_CURSOR,
|
||||||
USER_MARKER,
|
USER_LABEL,
|
||||||
buildThreadMarkdown,
|
buildThreadMarkdown,
|
||||||
previewText,
|
previewText,
|
||||||
quote,
|
quote,
|
||||||
@@ -46,16 +46,26 @@ describe("buildThreadMarkdown", () => {
|
|||||||
expect(markdown).toContain("4");
|
expect(markdown).toContain("4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks each speaker with a role icon", () => {
|
it("labels each speaker, in order", () => {
|
||||||
const markdown = buildThreadMarkdown([
|
const markdown = buildThreadMarkdown([
|
||||||
message({ role: "user", text: "q" }),
|
message({ role: "user", text: "q" }),
|
||||||
message({ role: "assistant", text: "a" }),
|
message({ role: "assistant", text: "a" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(markdown).toContain(USER_MARKER);
|
expect(markdown).toContain(`**${USER_LABEL}**`);
|
||||||
expect(markdown).toContain(ASSISTANT_MARKER);
|
expect(markdown).toContain(`**${ASSISTANT_LABEL}**`);
|
||||||
// The user marker must not leak onto the answer or vice versa.
|
expect(markdown.indexOf(USER_LABEL)).toBeLessThan(markdown.indexOf(ASSISTANT_LABEL));
|
||||||
expect(markdown.indexOf(USER_MARKER)).toBeLessThan(markdown.indexOf(ASSISTANT_MARKER));
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("keeps the conversation in order", () => {
|
||||||
|
|||||||
+182
-131
@@ -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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useChat } from "../hooks/useChat";
|
import { useChat } from "../hooks/useChat";
|
||||||
import { buildThreadMarkdown } from "../markdown";
|
import { buildThreadMarkdown } from "../markdown";
|
||||||
import { getLamPreferences } from "../preferences";
|
import { getLamPreferences } from "../preferences";
|
||||||
import { listSessions, loadSession } from "../sessions";
|
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||||
import { buildThread } from "../thread";
|
import { buildThread, type LiveState } from "../thread";
|
||||||
import type { Session, SessionSummary, ThreadMessage } from "../types";
|
import type { ChatTurn, Session, SessionSummary } from "../types";
|
||||||
import { truncate } from "../utils";
|
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
|
* Most of this is a consequence of how Vicinae renders lists rather than a free
|
||||||
* lists rather than a free choice:
|
* choice:
|
||||||
*
|
*
|
||||||
* - The search bar is the composer. It is the only always-focused text input in
|
* - 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.
|
* 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 conversation switcher, so the builtin
|
||||||
* - `filtering` is off. This list is a transcript, so the builtin fuzzy filter
|
* fuzzy filter would hide rows as the user types their question.
|
||||||
* would hide messages as the user types their next question.
|
* - The left column lists conversations; the right pane is the transcript. The
|
||||||
* - Rows are messages, not turns. Storage keeps a question and answer together
|
* two panes are not redundant because they show different things.
|
||||||
* because that is what gets replayed to the model; the transcript flattens
|
* - Every row carries that conversation's transcript as its detail, so moving
|
||||||
* them so each side gets its own icon and weight.
|
* the selection previews a conversation.
|
||||||
* - Rows are a fixed single line (38px in `ListItemDelegate.qml`). `title`
|
* - `Detail` accepts no typed input — `onSearchTextChange` exists only on
|
||||||
* renders in the foreground colour and `subtitle` in a muted one, so the
|
* `List`, `Grid`, `Form` and `Dropdown` — so the left column cannot be
|
||||||
* user's line goes in the subtitle to make it recede and the answer goes in
|
* dropped: it *is* the text box.
|
||||||
* the title to stand out. This is the only way to vary text weight from the
|
*
|
||||||
* extension API.
|
* Ordering matters here. Vicinae resets list selection to the first row
|
||||||
* - Messages are oldest first and the detail panel shows the whole thread. The
|
* whenever the model changes (`GenericListView.selectFirstOnReset`), and
|
||||||
* thread is shown rather than just the selected message because Vicinae
|
* streaming changes the model on every flush. Sessions are therefore ordered by
|
||||||
* resets list selection to the first row on every model change, and streaming
|
* most recently updated, which keeps the conversation being chatted in at row
|
||||||
* changes the model on every flush — so the selected row is not something
|
* zero where the selection lands.
|
||||||
* 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 IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||||
const NEW_SESSION_VALUE = "__new__";
|
|
||||||
|
|
||||||
export interface ChatViewProps {
|
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;
|
initialSession?: Session;
|
||||||
/** Text handed over by a deeplink or fallback command. */
|
/** Text handed over by a deeplink or fallback command. */
|
||||||
initialPrompt?: string;
|
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) {
|
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||||
const preferences = useMemo(() => getLamPreferences(), []);
|
const preferences = useMemo(() => getLamPreferences(), []);
|
||||||
const chat = useChat({ preferences, initialSession });
|
const chat = useChat({ preferences, initialSession });
|
||||||
const { replaceSession } = chat;
|
const { replaceSession } = chat;
|
||||||
|
|
||||||
const [input, setInput] = useState(initialPrompt ?? "");
|
const [input, setInput] = useState(initialPrompt ?? "");
|
||||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
const [stored, setStored] = useState<StoredConversation[] | undefined>(undefined);
|
||||||
const [ready, setReady] = useState(Boolean(initialSession));
|
const [resumed, setResumed] = useState(Boolean(initialSession));
|
||||||
|
|
||||||
const refreshSessions = useCallback(() => {
|
const activeId = chat.session.id;
|
||||||
void listSessions()
|
|
||||||
.then(setSessions)
|
/**
|
||||||
.catch(() => setSessions([]));
|
* 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(() => {
|
useEffect(() => {
|
||||||
refreshSessions();
|
reload();
|
||||||
}, [refreshSessions, chat.session.updatedAt]);
|
}, [reload, chat.session.updatedAt]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reopening the command continues the most recent conversation rather than
|
* Reopening the command continues the most recent conversation rather than
|
||||||
* starting a fresh chat every time. Starting over is explicit, via ⌘N or the
|
* starting a fresh chat every time. Starting over is explicit: ⌘N.
|
||||||
* "New chat" row in the dropdown.
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialSession) return;
|
if (initialSession) return;
|
||||||
@@ -81,7 +105,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
|||||||
const session = await loadSession(latest.id);
|
const session = await loadSession(latest.id);
|
||||||
if (!cancelled && session) replaceSession(session);
|
if (!cancelled && session) replaceSession(session);
|
||||||
} finally {
|
} 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],
|
[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(
|
const send = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -111,55 +190,47 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
|||||||
[chat.ask],
|
[chat.ask],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasPrompt = input.trim().length > 0;
|
||||||
|
|
||||||
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
|
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
|
||||||
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
|
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
|
||||||
|
|
||||||
const newChatAction = (
|
|
||||||
<Action
|
|
||||||
title="New Chat"
|
|
||||||
icon={Icon.Plus}
|
|
||||||
shortcut={Keyboard.Shortcut.Common.New}
|
|
||||||
onAction={chat.newSession}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every row carries the same primary action, so Return always sends whatever
|
* The primary action adapts to the search bar. With text in it, Return sends;
|
||||||
* is in the search bar whichever row happens to be selected. That is what
|
* with it empty — where sending would be a no-op anyway — Return opens the
|
||||||
* makes the window feel like a single input box rather than a menu.
|
* highlighted conversation instead.
|
||||||
*/
|
*/
|
||||||
const rowActions = (message: ThreadMessage) => (
|
const rowActions = (row: SessionRow) => {
|
||||||
<ActionPanel>
|
const openAction = (
|
||||||
{chat.isStreaming ? stopAction : sendAction}
|
<Action
|
||||||
<Action.CopyToClipboard
|
title="Open Conversation"
|
||||||
title={message.role === "user" ? "Copy Question" : "Copy Answer"}
|
icon={Icon.CheckCircle}
|
||||||
content={message.text}
|
onAction={() => openSession(row.id)}
|
||||||
shortcut={Keyboard.Shortcut.Common.Copy}
|
|
||||||
/>
|
/>
|
||||||
{newChatAction}
|
);
|
||||||
</ActionPanel>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownItems = useMemo(() => {
|
return (
|
||||||
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
|
<ActionPanel>
|
||||||
if (hasCurrent) return sessions;
|
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
|
||||||
|
{hasPrompt ? openAction : null}
|
||||||
|
<Action
|
||||||
|
title="New Chat"
|
||||||
|
icon={Icon.Plus}
|
||||||
|
shortcut={{ key: "n", modifiers: ["cmd"] }}
|
||||||
|
onAction={chat.newSession}
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
title="Delete Conversation"
|
||||||
|
icon={Icon.Trash}
|
||||||
|
style="destructive"
|
||||||
|
onAction={() => removeSession(row.id)}
|
||||||
|
/>
|
||||||
|
</ActionPanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
if (!resumed || stored === undefined) {
|
||||||
{
|
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||||
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…" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,61 +242,41 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
|||||||
onSearchTextChange={setInput}
|
onSearchTextChange={setInput}
|
||||||
navigationTitle="Lam"
|
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"
|
|
||||||
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={
|
actions={
|
||||||
<ActionPanel>
|
<ActionPanel>
|
||||||
{chat.isStreaming ? stopAction : sendAction}
|
{chat.isStreaming ? stopAction : sendAction}
|
||||||
{newChatAction}
|
<Action
|
||||||
|
title="New Chat"
|
||||||
|
icon={Icon.Plus}
|
||||||
|
shortcut={{ key: "n", modifiers: ["cmd"] }}
|
||||||
|
onAction={chat.newSession}
|
||||||
|
/>
|
||||||
</ActionPanel>
|
</ActionPanel>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{thread.length === 0 ? (
|
{rows.map((row) => {
|
||||||
<List.EmptyView
|
const accessories: List.Item.Accessory[] = [];
|
||||||
icon={Icon.Stars}
|
if (row.isActive) accessories.push({ tag: "Open" });
|
||||||
title="Ask Lam anything"
|
accessories.push({ text: formatRelativeTime(row.updatedAt) });
|
||||||
description="Type a question and press Return."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
thread.map((message) => {
|
|
||||||
const isUser = message.role === "user";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={message.id}
|
key={row.id}
|
||||||
id={message.id}
|
id={row.id}
|
||||||
icon={isUser ? Icon.Person : Icon.Stars}
|
icon={row.isActive ? Icon.CheckCircle : Icon.SpeechBubble}
|
||||||
// The renderer draws `title` bright and `subtitle` muted, so the
|
title={row.title}
|
||||||
// question lives in the subtitle and the answer in the title.
|
subtitle={row.preview}
|
||||||
title={isUser ? "" : message.preview}
|
accessories={accessories}
|
||||||
subtitle={isUser ? message.preview : undefined}
|
detail={
|
||||||
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={message.isStreaming} />}
|
<List.Item.Detail
|
||||||
actions={rowActions(message)}
|
markdown={row.isActive ? threadMarkdown : row.markdown}
|
||||||
/>
|
isLoading={row.isActive && chat.isStreaming}
|
||||||
);
|
/>
|
||||||
})
|
}
|
||||||
)}
|
actions={rowActions(row)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -15,14 +15,18 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
|||||||
export const STREAMING_CURSOR = "▍";
|
export const STREAMING_CURSOR = "▍";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role markers for the transcript.
|
* Speaker labels.
|
||||||
*
|
*
|
||||||
* Emoji rather than icon images: Vicinae's markdown renderer is minimal and
|
* Plain text, not icons. Vicinae's markdown renderer draws images as
|
||||||
* there is no reliable way to inline an `icon://` reference into markdown, but
|
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
|
||||||
* plain text always renders.
|
* 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 USER_LABEL = "You";
|
||||||
export const ASSISTANT_MARKER = "✨";
|
export const ASSISTANT_LABEL = "Lam";
|
||||||
|
|
||||||
/** 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 {
|
||||||
@@ -41,11 +45,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 `${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 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) {
|
if (message.error) {
|
||||||
sections.push("", `**Error** — ${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)}`;
|
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 {
|
function sessionKey(id: string): string {
|
||||||
return `${STORAGE_SESSION_PREFIX}${id}`;
|
return `${STORAGE_SESSION_PREFIX}${id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user