The preview rendered, but with dead space around it. `MdImage.qml` sizes its container to the request rather than to the image, so with no hint it is the full pane width and 200px tall and the image is aspect-fitted inside that. A wide capture sat in a letterbox: measured against the real captures in the support directory, a 4:1 crop wasted about 106px of the 200px container and a 6.7:1 crop about 140px, which is what made the transcript look gappy and put the caption far below the image. - `readAttachment` now reads the PNG `IHDR` chunk, so `Attachment` carries the real dimensions and they are persisted with the turn. - `previewBox` computes an exact box from those dimensions, scaled to fit 400x200 with the scale capped at 1 so a small capture is not upscaled, and `imageSource` emits it as the `raycast-width`/`raycast-height` hints `parseImageSize` looks for. The path is resolved from the URL before the query, so the hints never reach the file name. - The alt text is now "Screenshot". It is drawn centred under the image, so the sentence "Screenshot attached" read as a stray line of prose floating in the middle of the pane. - A pending note no longer stacks on top of the empty-transcript placeholder. Both said the same thing, one line under the other, once a snip was attached. A capture with no readable header simply falls back to the old sizing. Verified: 293 tests, tsc, eslint, prettier, and vici build.
406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
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<SessionSummary[] | undefined>(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<PendingAttachment | undefined>(undefined);
|
|
const [snipNote, setSnipNote] = useState<string | undefined>(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<TranscriptOptions>(
|
|
() => ({ 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 = <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={{ key: "n", modifiers: ["cmd"] }}
|
|
onAction={chat.newSession}
|
|
/>
|
|
);
|
|
|
|
/**
|
|
* 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 ? (
|
|
<Action
|
|
title="Snip a Region"
|
|
icon={Icon.Screenshot}
|
|
shortcut={{ key: "s", modifiers: ["cmd", "shift"] }}
|
|
onAction={() =>
|
|
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 ? (
|
|
<Action.Open title="Open Screenshot" icon={Icon.Image} target={latestAttachment.path} />
|
|
) : 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 = (
|
|
<Action title="Open Conversation" icon={Icon.CheckCircle} onAction={() => openSession(row.id)} />
|
|
);
|
|
|
|
return (
|
|
<ActionPanel>
|
|
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
|
|
{hasPrompt ? openAction : null}
|
|
{newChatAction}
|
|
{snipAction}
|
|
{openSnipAction}
|
|
<Action
|
|
title="Delete Conversation"
|
|
icon={Icon.Trash}
|
|
style="destructive"
|
|
// The key macOS labels "delete" is backspace. Vicinae's key map has
|
|
// `"backspace" -> Qt::Key_Backspace`, which it renders as the ⌫ token.
|
|
shortcut={{ key: "backspace", modifiers: ["cmd", "shift"] }}
|
|
onAction={() => removeSession(row.id)}
|
|
/>
|
|
</ActionPanel>
|
|
);
|
|
};
|
|
|
|
if (startup === "loading" || summaries === undefined) {
|
|
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
|
}
|
|
|
|
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 (
|
|
<List
|
|
isLoading={chat.isStreaming}
|
|
isShowingDetail
|
|
filtering={false}
|
|
searchText={input}
|
|
onSearchTextChange={setInput}
|
|
// Raycast puts the conversation title in a header; Vicinae's nearest
|
|
// equivalent is the status-bar title, so the open chat is named there too.
|
|
navigationTitle={truncate(chat.session.title, 40)}
|
|
searchBarPlaceholder={chat.isStreaming ? placeholder : idlePlaceholder}
|
|
actions={
|
|
<ActionPanel>
|
|
{chat.isStreaming ? stopAction : sendAction}
|
|
{newChatAction}
|
|
{snipAction}
|
|
{openSnipAction}
|
|
</ActionPanel>
|
|
}
|
|
>
|
|
{sections.map((section) => (
|
|
<List.Section key={section.title} title={section.title}>
|
|
{section.rows.map((row) => (
|
|
<List.Item
|
|
key={row.id}
|
|
id={row.id}
|
|
icon={row.isActive ? Icon.CheckCircle : Icon.SpeechBubble}
|
|
title={row.title}
|
|
// Title only. Raycast's sidebar shows nothing else, and stacking a
|
|
// subtitle plus a timestamp on a 38px row elided the title down to
|
|
// a few characters. The check icon is what marks the open chat.
|
|
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={chat.isStreaming} />}
|
|
actions={rowActions(row)}
|
|
/>
|
|
))}
|
|
</List.Section>
|
|
))}
|
|
</List>
|
|
);
|
|
}
|