feat: message-per-row transcript, session resume, drop the composer

Three changes from testing the launcher:

1. Remove the multi-line composer. It rendered as a small box floating
   in a large empty form. That is structural, not a styling miss: Vicinae
   always renders a Form.TextArea inside FormField, which reserves two
   ninths of the width for a label gutter and two ninths as an empty
   right spacer. With no way to reclaim that width, and no clean
   full-width variant available, prompts stay single line.

2. Resume the most recent conversation when the command opens. Every
   launch used to start a fresh chat, so there was no way to continue
   anything. Starting over is now explicit: cmd+N, or "New chat" in the
   dropdown.

3. One row per message instead of one row per turn. Storage keeps a
   question and its answer together because that is what gets replayed to
   the model; thread.ts flattens that so each side gets its own icon and
   weight. Rows are a fixed single line and the renderer draws title
   bright and subtitle muted, so the question goes in the subtitle and
   the answer in the title -- that is what dims the user and brightens
   Lam. Icons are Person and Stars.

The detail panel now shows the whole thread rather than the selected
message. 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.

Verified by running it: the existing saved session resumed and rendered
as four rows (two dim questions, two bright answers) with the thread in
the detail panel. typecheck, eslint, 107 tests and vici build all pass.
This commit is contained in:
2026-10-04 04:58:46 +08:00
parent af0c75a65d
commit 7d2423682a
8 changed files with 444 additions and 300 deletions
+10 -7
View File
@@ -54,13 +54,12 @@ 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 |
| `⌘ ↩` | Compose a longer, multi-line message |
| `Tab` | Jump to the conversation switcher in the search bar | | `Tab` | Jump to the conversation switcher in the search bar |
| `⌘ N` | New chat | | `⌘ N` | New chat |
| `⌘ ⇧ C` | Copy the highlighted answer | | `⌘ ⇧ C` | Copy the highlighted message |
| `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) | | `⌘ ⌫` | Delete a conversation (in **Chat Sessions**) |
Inside the composer: `↩` inserts a new line, `⌘ ↩` sends. 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.
### Suggested global shortcuts ### Suggested global shortcuts
@@ -132,20 +131,24 @@ src/
sessions.ts # LocalStorage persistence sessions.ts # LocalStorage persistence
history.ts # turns -> API messages history.ts # turns -> API messages
location.ts # time/place, derived locally location.ts # time/place, derived locally
markdown.ts # turn -> detail-panel markdown thread.ts # turns -> flat message list
markdown.ts # messages -> detail-panel markdown
preferences.ts # preferences, normalised preferences.ts # preferences, normalised
hooks/useChat.ts # the chat state machine hooks/useChat.ts # the chat state machine
components/ChatView.tsx # the chat surface components/ChatView.tsx # the chat surface
components/ComposeForm.tsx
``` ```
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 also single-line and there is no API to change that, which is the entire reason `ComposeForm` exists. `filtering` is off, because this list is a transcript — the builtin fuzzy filter would hide turns 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 this list is a transcript — the builtin fuzzy filter would hide messages 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. **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.
**Turns render newest first**, so the answer you just asked for is nearest the search bar instead of below the fold. **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.
**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.
**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.
**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.
+76 -52
View File
@@ -1,84 +1,103 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
STREAMING_CURSOR, STREAMING_CURSOR,
buildTurnMarkdown, buildThreadMarkdown,
pendingMarkdown,
previewText, previewText,
quote,
sessionToPlainText, sessionToPlainText,
} from "../lib/markdown"; } from "../lib/markdown";
import type { ChatTurn } from "../lib/types"; import type { ChatTurn, ThreadMessage } from "../lib/types";
describe("buildTurnMarkdown", () => { function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
it("renders the question as a quote block above the answer", () => { return {
const markdown = buildTurnMarkdown({ prompt: "What is 2+2?", response: "4" }); id: "m1",
text: "",
preview: "",
createdAt: 0,
isStreaming: false,
thinking: "",
toolCalls: [],
...overrides,
};
}
expect(markdown).toContain("> What is 2+2?"); describe("quote", () => {
expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4")); 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", () => { it("renders a question as a quote and an answer as body text", () => {
const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" }); 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", () => { it("marks a streaming answer with a cursor", () => {
const streaming = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: true }); const streaming = buildThreadMarkdown([
const finished = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: false }); message({ role: "assistant", text: "partial", isStreaming: true }),
]);
const finished = buildThreadMarkdown([message({ role: "assistant", text: "partial" })]);
expect(streaming).toContain(STREAMING_CURSOR); expect(streaming).toContain(STREAMING_CURSOR);
expect(finished).not.toContain(STREAMING_CURSOR); expect(finished).not.toContain(STREAMING_CURSOR);
}); });
it("shows a placeholder while an answer is still empty", () => { it("shows a placeholder while an answer has no text yet", () => {
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: true })).toContain("Thinking"); expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking");
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: false })).toContain("No response"); expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response");
}); });
it("surfaces an error instead of hiding it", () => { 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."); expect(markdown).toContain("Ollama rejected the API key.");
}); });
it("lists tools that were used, flagging failures", () => { it("lists tools that were used, flagging failures", () => {
const markdown = buildTurnMarkdown({ const markdown = buildThreadMarkdown([
prompt: "q", message({
response: "a", role: "assistant",
toolCalls: [ text: "a",
{ name: "web_search", arguments: {}, result: "ok", isError: false }, toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true }, { 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_search`");
expect(markdown).toContain("`web_fetch` — failed"); expect(markdown).toContain("`web_fetch` — failed");
}); });
it("omits the reasoning section when there is none", () => { it("only shows a reasoning section when there is reasoning", () => {
expect(buildTurnMarkdown({ prompt: "q", response: "a" })).not.toContain("Reasoning"); expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning");
expect(buildTurnMarkdown({ prompt: "q", response: "a", thinking: "hmm" })).toContain("Reasoning"); expect(buildThreadMarkdown([message({ role: "assistant", text: "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…_");
}); });
}); });
@@ -90,6 +109,10 @@ describe("previewText", () => {
it("clips with an ellipsis", () => { it("clips with an ellipsis", () => {
expect(previewText("a".repeat(50), 5)).toHaveLength(5); expect(previewText("a".repeat(50), 5)).toHaveLength(5);
}); });
it("leaves short text alone", () => {
expect(previewText("hi", 40)).toBe("hi");
});
}); });
describe("sessionToPlainText", () => { describe("sessionToPlainText", () => {
@@ -111,7 +134,8 @@ describe("sessionToPlainText", () => {
}); });
it("includes the failure when a turn errored", () => { it("includes the failure when a turn errored", () => {
const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]); expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
expect(text).toContain("Ollama rejected the API key."); "Ollama rejected the API key.",
);
}); });
}); });
+116
View File
@@ -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
View File
@@ -1,63 +1,58 @@
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api"; import { Action, ActionPanel, Icon, Keyboard, 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 { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown"; import { buildThreadMarkdown } from "../markdown";
import { getLamPreferences } from "../preferences"; import { getLamPreferences } from "../preferences";
import { listSessions, loadSession } from "../sessions"; import { listSessions, loadSession } from "../sessions";
import type { ChatTurn, Session, SessionSummary } from "../types"; import { buildThread } from "../thread";
import { formatRelativeTime, truncate } from "../utils"; import type { Session, SessionSummary, ThreadMessage } from "../types";
import { ComposeForm } from "./ComposeForm"; import { truncate } from "../utils";
/** /**
* The chat surface. * The chat surface.
* *
* Layout notes, because they are consequences of how Vicinae renders lists * Layout notes, because most of this is a consequence of how Vicinae renders
* rather than free choices: * lists rather than a free choice:
* *
* - The search bar doubles as the composer. It is the only always-focused text * - The search bar is the composer. It is the only always-focused text input in
* input in a List, which is what makes "hit a hotkey and just start typing" * a List, which is what makes "hit a hotkey, then just start typing" work.
* work. It is single-line, hence the separate ⌘↩ composer. * It is single-line; there is no multi-line composer by design.
* - `filtering` is off. This list is a transcript, not a set of search results, * - `filtering` is off. This list is a transcript, so the builtin fuzzy filter
* so the builtin fuzzy filter would hide turns as the user types a question. * would hide messages as the user types their next question.
* - Turns render newest first. The answer the user just asked for should be the * - Rows are messages, not turns. Storage keeps a question and answer together
* thing nearest the search bar, not something they have to scroll to. * because that is what gets replayed to the model; the transcript flattens
* - Tab reaches the session dropdown in the search bar accessory, which is how * them so each side gets its own icon and weight.
* "Tab to move through previous conversations" is wired. * - 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. */ /** Sentinel value for the dropdown row that starts a fresh conversation. */
const NEW_SESSION_VALUE = "__new__"; const NEW_SESSION_VALUE = "__new__";
export interface ChatViewProps { 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; initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */ /** Text handed over by a deeplink or fallback command. */
initialPrompt?: string; 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) { 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 [input, setInput] = useState(initialPrompt ?? ""); const [input, setInput] = useState(initialPrompt ?? "");
const [sessions, setSessions] = useState<SessionSummary[]>([]); const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [ready, setReady] = useState(Boolean(initialSession));
const refreshSessions = useCallback(() => { const refreshSessions = useCallback(() => {
void listSessions() void listSessions()
@@ -70,6 +65,44 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
refreshSessions(); refreshSessions();
}, [refreshSessions, chat.session.updatedAt]); }, [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( const send = useCallback(
(text: string) => { (text: string) => {
chat.ask(text); chat.ask(text);
@@ -78,22 +111,8 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.ask], [chat.ask],
); );
const stopAction = ( const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
<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 composeAction = (
<Action.Push
title="Compose Longer Message"
icon={Icon.Text}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
target={<ComposeForm initialValue={input} onSubmit={send} />}
/>
);
const newChatAction = ( const newChatAction = (
<Action <Action
@@ -105,45 +124,43 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
); );
/** /**
* Every row carries the same primary action. Enter therefore always sends the * Every row carries the same primary action, so Return always sends whatever
* current search text, whichever row happens to be selected, which is what * is in the search bar whichever row happens to be selected. That is what
* makes the whole window feel like a single input box. * makes the window feel like a single input box rather than a menu.
*/ */
const rowActions = (turn: ChatTurn) => ( const rowActions = (message: ThreadMessage) => (
<ActionPanel> <ActionPanel>
{chat.isStreaming ? stopAction : sendAction} {chat.isStreaming ? stopAction : sendAction}
{composeAction}
<Action.CopyToClipboard <Action.CopyToClipboard
title="Copy Answer" title={message.role === "user" ? "Copy Question" : "Copy Answer"}
content={turn.response} content={message.text}
shortcut={Keyboard.Shortcut.Common.Copy} shortcut={Keyboard.Shortcut.Common.Copy}
/> />
<Action.CopyToClipboard title="Copy Question" content={turn.prompt} />
{newChatAction} {newChatAction}
</ActionPanel> </ActionPanel>
); );
const dropdownItems = useMemo(() => { const dropdownItems = useMemo(() => {
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id); const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
const withCurrent: SessionSummary[] = hasCurrent if (hasCurrent) return sessions;
? 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,
];
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]); }, [sessions, chat.session]);
const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]); if (!ready) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversation…" />;
}
return ( return (
<List <List
@@ -153,9 +170,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
searchText={input} searchText={input}
onSearchTextChange={setInput} onSearchTextChange={setInput}
navigationTitle="Lam" navigationTitle="Lam"
searchBarPlaceholder={ searchBarPlaceholder={chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"}
chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"
}
searchBarAccessory={ searchBarAccessory={
<List.Dropdown <List.Dropdown
tooltip="Switch conversation" 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.Item title="+ New chat" value={NEW_SESSION_VALUE} />
<List.Dropdown.Section title="Recent"> <List.Dropdown.Section title="Recent">
{dropdownItems.map((summary) => ( {dropdownItems.map((summary) => (
<List.Dropdown.Item <List.Dropdown.Item key={summary.id} title={truncate(summary.title, 44)} value={summary.id} />
key={summary.id}
title={truncate(summary.title, 44)}
value={summary.id}
/>
))} ))}
</List.Dropdown.Section> </List.Dropdown.Section>
</List.Dropdown> </List.Dropdown>
@@ -186,49 +197,31 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
actions={ actions={
<ActionPanel> <ActionPanel>
{chat.isStreaming ? stopAction : sendAction} {chat.isStreaming ? stopAction : sendAction}
{composeAction}
{newChatAction} {newChatAction}
</ActionPanel> </ActionPanel>
} }
> >
{orderedTurns.length === 0 ? ( {thread.length === 0 ? (
<List.EmptyView <List.EmptyView
icon={Icon.Stars} icon={Icon.Stars}
title="Ask Lam anything" 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) => { thread.map((message) => {
const live = index === 0 && chat.isStreaming; const isUser = message.role === "user";
const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }];
if (turn.toolCalls.length > 0) {
accessories.unshift({ tag: `${turn.toolCalls.length} tools` });
}
return ( return (
<List.Item <List.Item
key={turn.id} key={message.id}
id={turn.id} id={message.id}
icon={live ? Icon.ArrowClockwise : Icon.SpeechBubble} icon={isUser ? Icon.Person : Icon.Stars}
title={truncate(turn.prompt, 72)} // The renderer draws `title` bright and `subtitle` muted, so the
subtitle={ // question lives in the subtitle and the answer in the title.
live ? (chat.status ?? "Thinking…") : previewText(turn.response || turn.error || "", 90) title={isUser ? "" : message.preview}
} subtitle={isUser ? message.preview : undefined}
accessories={accessories} detail={<List.Item.Detail markdown={threadMarkdown} isLoading={message.isStreaming} />}
detail={ actions={rowActions(message)}
<List.Item.Detail
markdown={detailMarkdown(
turn,
live,
chat.streamingResponse,
chat.streamingThinking,
chat.status,
)}
isLoading={live}
/>
}
actions={rowActions(turn)}
/> />
); );
}) })
-76
View File
@@ -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
View File
@@ -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 * Kept as pure functions so the transcript layout can be tested without
* mounting a component, and so the same renderer can serve both a finished * mounting a component.
* turn and one that is still streaming. *
* 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 const STREAMING_CURSOR = "▍";
export interface TurnMarkdownInput { /** Prefixes every line so a multi-line message stays one continuous quote block. */
prompt: string; export function quote(text: string): 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 {
return text return text
.split("\n") .split("\n")
.map((line) => `> ${line}`.trimEnd()) .map((line) => `> ${line}`.trimEnd())
.join("\n"); .join("\n");
} }
export function buildTurnMarkdown(input: TurnMarkdownInput): string { /** A short one-line preview of a message, for a list row. */
const sections: string[] = []; 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)); function renderMessage(message: ThreadMessage): string {
sections.push(""); if (message.role === "user") {
return `**You**\n\n${quote(message.text)}`;
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}`);
} }
const toolCalls = input.toolCalls ?? []; const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
if (toolCalls.length > 0) { const sections = ["**Lam**", "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
const lines = toolCalls.map((call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`);
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); sections.push("", "**Tools used**", ...lines);
} }
if (input.thinking && input.thinking.trim().length > 0) { if (message.thinking.trim().length > 0) {
sections.push("", "---", "", "**Reasoning**", "", quote(input.thinking.trim())); sections.push("", "_Reasoning_", "", quote(message.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(" · "));
} }
return sections.join("\n"); return sections.join("\n");
} }
/** The placeholder shown while the first tokens are still in flight. */ /**
export function pendingMarkdown(prompt: string, status?: string): string { * Renders the whole conversation.
const lines = [quote(prompt), ""]; *
lines.push(status ? `_${status}…_` : "_Thinking…_"); * The detail panel shows the full thread rather than just the selected message
return lines.join("\n"); * 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
/** A short one-line preview of an answer, for a collapsed row. */ * arrives. Showing the thread means the live answer is always on screen.
export function previewText(text: string, max: number): string { */
const collapsed = text.replace(/\s+/g, " ").trim(); export function buildThreadMarkdown(messages: ThreadMessage[]): string {
if (collapsed.length <= max) return collapsed; if (messages.length === 0) return "";
return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`; return messages.map(renderMessage).join("\n\n---\n\n");
} }
/** /**
+72
View File
@@ -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";
}
+20
View File
@@ -85,3 +85,23 @@ export interface ModelInfo {
name: string; name: string;
parameterSize?: 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;
}