feat: initial Lam Agent extension (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import { listSessions, loadSession } from "../sessions";
|
||||
import type { ChatTurn, Session, SessionSummary } from "../types";
|
||||
import { formatRelativeTime, truncate } from "../utils";
|
||||
import { ComposeForm } from "./ComposeForm";
|
||||
|
||||
/**
|
||||
* The chat surface.
|
||||
*
|
||||
* Layout notes, because they are consequences of how Vicinae renders lists
|
||||
* rather than free choices:
|
||||
*
|
||||
* - The search bar doubles as the composer. It is the only always-focused text
|
||||
* input in a List, which is what makes "hit a hotkey and just start typing"
|
||||
* work. It is single-line, hence the separate ⌘↩ composer.
|
||||
* - `filtering` is off. This list is a transcript, not a set of search results,
|
||||
* so the builtin fuzzy filter would hide turns as the user types a question.
|
||||
* - Turns render newest first. The answer the user just asked for should be the
|
||||
* thing nearest the search bar, not something they have to scroll to.
|
||||
* - Tab reaches the session dropdown in the search bar accessory, which is how
|
||||
* "Tab to move through previous conversations" is wired.
|
||||
*/
|
||||
|
||||
/** Sentinel value for the dropdown row that starts a fresh conversation. */
|
||||
const NEW_SESSION_VALUE = "__new__";
|
||||
|
||||
export interface ChatViewProps {
|
||||
/** Reopens an existing conversation instead of starting a new one. */
|
||||
initialSession?: Session;
|
||||
/** Text handed over by a deeplink or fallback command. */
|
||||
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) {
|
||||
const preferences = useMemo(() => getLamPreferences(), []);
|
||||
const chat = useChat({ preferences, initialSession });
|
||||
|
||||
const [input, setInput] = useState(initialPrompt ?? "");
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
|
||||
const refreshSessions = useCallback(() => {
|
||||
void listSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => setSessions([]));
|
||||
}, []);
|
||||
|
||||
// Refreshed after each turn so the Tab dropdown picks up the newly saved chat.
|
||||
useEffect(() => {
|
||||
refreshSessions();
|
||||
}, [refreshSessions, chat.session.updatedAt]);
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
chat.ask(text);
|
||||
setInput("");
|
||||
},
|
||||
[chat.ask],
|
||||
);
|
||||
|
||||
const stopAction = (
|
||||
<Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />
|
||||
);
|
||||
|
||||
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 = (
|
||||
<Action
|
||||
title="New Chat"
|
||||
icon={Icon.Plus}
|
||||
shortcut={Keyboard.Shortcut.Common.New}
|
||||
onAction={chat.newSession}
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Every row carries the same primary action. Enter therefore always sends the
|
||||
* current search text, whichever row happens to be selected, which is what
|
||||
* makes the whole window feel like a single input box.
|
||||
*/
|
||||
const rowActions = (turn: ChatTurn) => (
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : sendAction}
|
||||
{composeAction}
|
||||
<Action.CopyToClipboard
|
||||
title="Copy Answer"
|
||||
content={turn.response}
|
||||
shortcut={Keyboard.Shortcut.Common.Copy}
|
||||
/>
|
||||
<Action.CopyToClipboard title="Copy Question" content={turn.prompt} />
|
||||
{newChatAction}
|
||||
</ActionPanel>
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
|
||||
const withCurrent: SessionSummary[] = hasCurrent
|
||||
? 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;
|
||||
}, [sessions, chat.session]);
|
||||
|
||||
const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]);
|
||||
|
||||
return (
|
||||
<List
|
||||
isLoading={chat.isStreaming}
|
||||
isShowingDetail
|
||||
filtering={false}
|
||||
searchText={input}
|
||||
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}
|
||||
{composeAction}
|
||||
{newChatAction}
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
{orderedTurns.length === 0 ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.Stars}
|
||||
title="Ask Lam anything"
|
||||
description="Type a question and press Return. Use ⌘↩ for a longer, multi-line message."
|
||||
/>
|
||||
) : (
|
||||
orderedTurns.map((turn, index) => {
|
||||
const live = index === 0 && chat.isStreaming;
|
||||
|
||||
const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }];
|
||||
if (turn.toolCalls.length > 0) {
|
||||
accessories.unshift({ tag: `${turn.toolCalls.length} tools` });
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={turn.id}
|
||||
id={turn.id}
|
||||
icon={live ? Icon.ArrowClockwise : Icon.SpeechBubble}
|
||||
title={truncate(turn.prompt, 72)}
|
||||
subtitle={
|
||||
live ? (chat.status ?? "Thinking…") : previewText(turn.response || turn.error || "", 90)
|
||||
}
|
||||
accessories={accessories}
|
||||
detail={
|
||||
<List.Item.Detail
|
||||
markdown={detailMarkdown(
|
||||
turn,
|
||||
live,
|
||||
chat.streamingResponse,
|
||||
chat.streamingThinking,
|
||||
chat.status,
|
||||
)}
|
||||
isLoading={live}
|
||||
/>
|
||||
}
|
||||
actions={rowActions(turn)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user