feat: conversations on the left, the transcript on the right (#6)
This commit was merged in pull request #6.
This commit is contained in:
+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 { 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 = (
|
||||
<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
|
||||
* 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.
|
||||
* 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 = (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}
|
||||
const rowActions = (row: SessionRow) => {
|
||||
const openAction = (
|
||||
<Action
|
||||
title="Open Conversation"
|
||||
icon={Icon.CheckCircle}
|
||||
onAction={() => openSession(row.id)}
|
||||
/>
|
||||
{newChatAction}
|
||||
</ActionPanel>
|
||||
);
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
|
||||
if (hasCurrent) return sessions;
|
||||
return (
|
||||
<ActionPanel>
|
||||
{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 [
|
||||
{
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
return (
|
||||
<List.Item
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user