import { Action, ActionPanel, Icon, LaunchType, List, launchCommand } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { readAttachment } from "../attachments";
import { runAutoDelete } from "../cleanup";
import { useChat, type ToolActivity } from "../hooks/useChat";
import { usePreferences } from "../hooks/usePreferences";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
import type { ResolvedPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { resolveStartup } from "../startup";
import { buildThread } from "../thread";
import type { PendingAttachment, Session, SessionSummary } from "../types";
import { recencyGroup, truncate } from "../utils";
/**
* The chat surface: conversations on the left, the open conversation on the right.
*
* Modelled on Raycast's AI chat, as far as Vicinae allows:
*
* - The sidebar lists conversations by title only, grouped into recency
* sections, which buys the recency cue a timestamp on every row was trying to
* convey while leaving the title room to be readable.
* - User and assistant stay visually distinct. Raycast uses right-aligned
* bubbles; markdown has no alignment control, so a dimmed blockquote for the
* question and body text for the answer is the available equivalent.
* - Raycast pins its composer to the bottom. Vicinae's search bar is fixed at
* the top of the window, so that part cannot be matched.
*
* ## Why every row carries the same detail
*
* The right pane always shows the *open* conversation, not the highlighted one.
* That is by design rather than laziness: Vicinae exposes no way to set the
* selected row, so when ⌘N starts a new chat, the selection can stay on the row
* the user was on and the pane would keep rendering the previous conversation.
* Rendering the active thread on every row makes the pane follow the chat the
* user is actually in, whatever the selection happens to be. Switching
* conversations is explicit: Return on an empty search bar, or the action panel.
*
* ## Other constraints
*
* - 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.
* - `filtering` is off, because the left column is a conversation switcher. The
* builtin fuzzy filter would hide rows as the user types their question.
* - `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.
* - 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, so ordering by recency keeps the chat in progress near the top.
* - Capturing a region does **not** happen here. It is delegated to the `snip`
* command, because this view owns a window and the window would be in the
* shot. See {@link ChatViewProps}.
*/
const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search bar and press Return.";
/** Sent when a snip is attached and Return is pressed before anything is typed. */
const DEFAULT_IMAGE_PROMPT = "What is in this image?";
/** The reminder shown while a snip is attached but not yet sent. */
const PENDING_NOTE = "_Ask a question about it, or press Return to send it as it is._";
/**
* The line that shows what Lam is doing right now.
*
* A count on its own reads as progress, so the second time round says what it is:
* the same call, asked again. That is the visible half of the loop guard in
* `agent.ts`, and it is the only warning the user gets before a turn is cut short.
*/
function describeActivity(activity: ToolActivity): string {
if (activity.count <= 1) return `_${activity.label}…_`;
return `_${activity.label} · the same call again (${activity.count})_`;
}
export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */
initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */
initialPrompt?: string;
/** A conversation to reload by id, which is how the snip command hands one back. */
initialSessionId?: string;
/** A captured image to read in, which is how the snip command hands one over. */
initialAttachmentPath?: string;
/** Something the launching command needs to say, such as a failed capture. */
launchNote?: string;
}
interface SessionRow {
id: string;
title: string;
updatedAt: number;
isActive: boolean;
}
/** Shown while the conversation and the preferences are still being read. */
function LoadingChat() {
return
;
}
/**
* The chat surface, once its preferences are known.
*
* Split in two so that {@link ChatView} can wait for the model before the state
* machine starts. `useChat` is created with the preferences it will use, so
* handing it preferences that are still loading would pin the conversation to
* whatever the preference said before the selection arrived.
*
* The outer half does nothing but that wait, and the loading state costs one
* LocalStorage read, which is a few milliseconds while the user is still moving
* their hands towards the keyboard.
*/
export function ChatView(props: ChatViewProps) {
const { preferences } = usePreferences();
if (!preferences) return ;
return ;
}
interface ChatSessionProps extends ChatViewProps {
preferences: ResolvedPreferences;
}
function ChatSession({
preferences,
initialSession,
initialPrompt,
initialSessionId,
initialAttachmentPath,
launchNote,
}: ChatSessionProps) {
// The capability check is resolved before the chat exists, because its answer
// is part of the system prompt: a model that knows it can read images describes
// the one it is sent, and one that does not know reasons about itself instead.
const vision = useVisionSupport(preferences);
const chat = useChat({ preferences, initialSession, vision: vision.support });
const { replaceSession } = chat;
const { canSeeImages } = vision;
const { activity } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [summaries, setSummaries] = useState(undefined);
// One flag, not two. See the startup effect below for why that matters.
const [startup, setStartup] = useState<"loading" | "ready">(initialSession ? "ready" : "loading");
// A captured image is held here until the user says what they want to know
// about it. Nothing is sent to the model on capture alone.
const [pending, setPending] = useState(undefined);
const [snipNote, setSnipNote] = useState(undefined);
const activeId = chat.session.id;
const reload = useCallback(() => {
void listSessions()
.then(setSummaries)
.catch(() => setSummaries([]));
}, []);
// Refreshed after each turn so the sidebar picks up a newly created chat, and
// swept at the same time: a turn is exactly when a conversation's age changes,
// and waiting for the next launch to collect an expired one would mean showing
// the user a conversation that has already been deleted.
useEffect(() => {
// Not until startup has finished, because until then the open conversation is
// still the placeholder one and the real conversation being restored has no
// id to be exempted by. Sweeping first could delete the chat on its way to
// the screen.
if (startup !== "ready") return;
let cancelled = false;
void (async () => {
// The open conversation is exempt: it is about to be rendered, and being
// the one it opens by default is the clearest sign it is still in use.
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs, keepId: chat.session.id });
if (!cancelled) reload();
})();
return () => {
cancelled = true;
};
}, [startup, chat.session.id, chat.session.updatedAt, preferences.autoDeleteMs, reload]);
/**
* Everything that has to happen before the first render, in one place.
*
* This used to be two effects guarding two booleans, and a hand-off launch
* satisfied the guard of *both* while setting neither, so `resumed` stayed
* false and the view sat on "Loading conversations…" forever. Anything that
* blocks the first render belongs in one effect with one flag, and the flag is
* cleared in a `finally` so that no branch, including a thrown one, can leave
* the view spinning.
*/
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const outcome = await resolveStartup(
{
hasInitialSession: Boolean(initialSession),
initialSessionId,
initialAttachmentPath,
resumeTimeoutMs: preferences.resumeTimeoutMs,
now: Date.now(),
},
{ loadSession, listSessions, readAttachment },
);
if (cancelled) return;
if (outcome.session) replaceSession(outcome.session);
if (outcome.attachment) setPending(outcome.attachment);
if (outcome.attachmentError) {
setSnipNote(`**Could not open the screenshot:** ${outcome.attachmentError}`);
}
} finally {
if (!cancelled) setStartup("ready");
}
})();
return () => {
cancelled = true;
};
}, [initialSession, initialSessionId, initialAttachmentPath, preferences.resumeTimeoutMs, 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 transcriptOptions = useMemo(
() => ({ showToolCalls: preferences.showToolCalls, showReasoning: preferences.showReasoning }),
[preferences.showToolCalls, preferences.showReasoning],
);
/**
* The transcript, preceded by any notes about this view's own state.
*
* Notes go first because the transcript reads newest-first: the top of the
* panel is the text directly under the search bar, which is where the user is
* already looking.
*/
const threadMarkdown = useMemo(() => {
const notes = [
launchNote,
snipNote,
// The preview comes first so the captured image is visible before the user
// decides what to ask about it, rather than only after sending.
pending ? `${renderAttachments([pending])}\n\n${PENDING_NOTE}` : undefined,
// Deliberately not gated on the Show Tool Calls preference. That setting is
// about the finished transcript, where a list of names adds nothing. This
// is about the turn in flight, where the same tool coming round a second
// time is the difference between a long answer and a stuck one, and hiding
// it would leave the user watching a spinner they cannot interpret.
activity ? describeActivity(activity) : undefined,
].filter((note): note is string => Boolean(note));
// The placeholder is what tells a user that the search bar is the composer.
// When a note above already says that, printing both put the same
// instruction twice, one line under the other.
const empty = notes.length === 0 ? EMPTY_TRANSCRIPT : "";
const transcript =
thread.length === 0 ? empty : buildThreadMarkdown(thread, transcriptOptions);
return [...notes, transcript].filter((part) => part.length > 0).join("\n\n");
}, [launchNote, snipNote, pending, activity, thread, transcriptOptions]);
const rows: SessionRow[] = useMemo(() => {
const base = summaries ?? [];
const mapped: SessionRow[] = base.map((summary) => ({
id: summary.id,
title: summary.title,
updatedAt: summary.updatedAt,
isActive: summary.id === activeId,
}));
// A conversation that has not finished a turn yet is not in the index.
if (base.some((summary) => summary.id === activeId)) return mapped;
const active = summarizeSession(chat.session);
return [{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true }, ...mapped];
}, [summaries, activeId, chat.session]);
/**
* Groups the sidebar into recency sections. Rows are already sorted by
* recency, so each group stays contiguous and can be built in one pass.
*/
const sections = useMemo(() => {
const grouped: { title: string; rows: SessionRow[] }[] = [];
for (const row of rows) {
const label = recencyGroup(row.updatedAt);
const current = grouped[grouped.length - 1];
if (current && current.title === label) current.rows.push(row);
else grouped.push({ title: label, rows: [row] });
}
return grouped;
}, [rows]);
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],
);
/** The snip a "show me the screenshot" action would open: the pending one, else the newest. */
const latestAttachment =
pending ?? [...chat.session.turns].reverse().find((turn) => turn.attachments.length > 0)?.attachments[0];
const send = useCallback(
(text: string) => {
const typed = text.trim();
// With a snip attached, Return has to send even before anything is typed,
// otherwise the only way to ask about an image is to type out a question
// the image already implies.
const prompt = typed || (pending ? DEFAULT_IMAGE_PROMPT : "");
if (!prompt) return;
chat.ask(prompt, pending);
setPending(undefined);
setSnipNote(undefined);
setInput("");
},
[chat.ask, pending],
);
const hasPrompt = input.trim().length > 0 || pending !== undefined;
const stopAction = ;
const sendAction = send(input)} />;
const newChatAction = (
);
/**
* Hands the capture to the `snip` command instead of doing it here.
*
* Two reasons, both structural. The window has to be out of the way, and only
* the snip command is positioned to close it before the selector appears. And
* launching a different command is what makes the hand-off safe: Vicinae's
* `activateEntrypoint` treats a launch of the *currently active* view command
* as a toggle and closes the window instead of reopening it, so relaying
* through `snip` avoids ever asking for `chat` while `chat` is active.
*
* Absent while streaming, because the hand-off unloads this view and would
* abandon an answer mid-sentence.
*/
const snipAction =
canSeeImages && !chat.isStreaming ? (
void launchCommand({
name: "snip",
type: LaunchType.UserInitiated,
context: { sessionId: chat.session.id },
})
}
/>
) : null;
// A screenshot is a file, so it opens in the system image viewer. Without this
// the only way to see what was sent would be to find it on disk by hand.
const openSnipAction = latestAttachment ? (
) : null;
/**
* 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. A pending snip counts as text, so Return
* sends it rather than navigating away.
*/
const rowActions = (row: SessionRow) => {
const openAction = (
openSession(row.id)} />
);
return (
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
{hasPrompt ? openAction : null}
{newChatAction}
{snipAction}
{openSnipAction}
Qt::Key_Backspace`, which it renders as the ⌫ token.
shortcut={{ key: "backspace", modifiers: ["cmd", "shift"] }}
onAction={() => removeSession(row.id)}
/>
);
};
if (startup === "loading" || summaries === undefined) {
return ;
}
const placeholder = chat.status ? `${chat.status}. Press ⏎ to stop` : "Lam is thinking. Press ⏎ to stop";
const idlePlaceholder = pending ? "Ask about the screenshot…" : "Ask Lam anything…";
return (
{chat.isStreaming ? stopAction : sendAction}
{newChatAction}
{snipAction}
{openSnipAction}
}
>
{sections.map((section) => (
{section.rows.map((row) => (
}
actions={rowActions(row)}
/>
))}
))}
);
}