import { Action, ActionPanel, Icon, LaunchType, List, launchCommand } from "@vicinae/api"; import { useCallback, useEffect, useMemo, useState } from "react"; import { readAttachment } from "../attachments"; import { useChat } from "../hooks/useChat"; import { useVisionSupport } from "../hooks/useVision"; import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown"; import { getLamPreferences } 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._"; 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; } export function ChatView({ initialSession, initialPrompt, initialSessionId, initialAttachmentPath, launchNote, }: ChatViewProps) { const preferences = useMemo(() => getLamPreferences(), []); const chat = useChat({ preferences, initialSession }); const { replaceSession } = chat; const { canSeeImages } = useVisionSupport(preferences); 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. useEffect(() => { reload(); }, [reload, chat.session.updatedAt]); /** * 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, ].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, 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)} /> ))} ))} ); }