feat: snip a region into chat, and make links clickable (#14)
This commit was merged in pull request #14.
This commit is contained in:
+151
-38
@@ -1,10 +1,14 @@
|
||||
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
|
||||
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 { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
|
||||
import { useVisionSupport } from "../hooks/useVision";
|
||||
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { buildThread } from "../thread";import type { Session, SessionSummary } from "../types";
|
||||
import { resolveStartup } from "../startup";
|
||||
import { buildThread } from "../thread";
|
||||
import type { PendingAttachment, Session, SessionSummary } from "../types";
|
||||
import { recencyGroup, truncate } from "../utils";
|
||||
|
||||
/**
|
||||
@@ -44,15 +48,30 @@ import { recencyGroup, truncate } from "../utils";
|
||||
* 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 {
|
||||
@@ -62,14 +81,26 @@ interface SessionRow {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
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);
|
||||
const [resumed, setResumed] = useState(Boolean(initialSession));
|
||||
// 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;
|
||||
|
||||
@@ -85,35 +116,47 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
}, [reload, chat.session.updatedAt]);
|
||||
|
||||
/**
|
||||
* Reopening the command continues the most recent conversation, but only if it
|
||||
* was used recently. Past the configured idle window it starts a fresh chat
|
||||
* instead. The previous conversation is never removed, it just drops down the
|
||||
* sidebar. Setting the preference to "Never" keeps the last chat open forever.
|
||||
* 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(() => {
|
||||
if (initialSession) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [latest] = await listSessions();
|
||||
if (!latest) return;
|
||||
const outcome = await resolveStartup(
|
||||
{
|
||||
hasInitialSession: Boolean(initialSession),
|
||||
initialSessionId,
|
||||
initialAttachmentPath,
|
||||
resumeTimeoutMs: preferences.resumeTimeoutMs,
|
||||
now: Date.now(),
|
||||
},
|
||||
{ loadSession, listSessions, readAttachment },
|
||||
);
|
||||
|
||||
const idleMs = Date.now() - latest.updatedAt;
|
||||
const { resumeTimeoutMs } = preferences;
|
||||
if (resumeTimeoutMs !== undefined && idleMs > resumeTimeoutMs) return;
|
||||
if (cancelled) return;
|
||||
|
||||
const session = await loadSession(latest.id);
|
||||
if (!cancelled && session) replaceSession(session);
|
||||
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) setResumed(true);
|
||||
if (!cancelled) setStartup("ready");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSession, preferences, replaceSession]);
|
||||
}, [initialSession, initialSessionId, initialAttachmentPath, preferences.resumeTimeoutMs, replaceSession]);
|
||||
|
||||
const thread = useMemo(
|
||||
() =>
|
||||
@@ -131,11 +174,31 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
[preferences.showToolCalls, preferences.showReasoning],
|
||||
);
|
||||
|
||||
const threadMarkdown = useMemo(
|
||||
() =>
|
||||
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions),
|
||||
[thread, transcriptOptions],
|
||||
);
|
||||
/**
|
||||
* 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 ?? [];
|
||||
@@ -151,10 +214,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
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,
|
||||
];
|
||||
return [{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true }, ...mapped];
|
||||
}, [summaries, activeId, chat.session]);
|
||||
|
||||
/**
|
||||
@@ -194,15 +254,28 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
[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) => {
|
||||
chat.ask(text);
|
||||
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],
|
||||
[chat.ask, pending],
|
||||
);
|
||||
|
||||
const hasPrompt = input.trim().length > 0;
|
||||
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)} />;
|
||||
@@ -216,10 +289,46 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* highlighted conversation instead. A pending snip counts as text, so Return
|
||||
* sends it rather than navigating away.
|
||||
*/
|
||||
const rowActions = (row: SessionRow) => {
|
||||
const openAction = (
|
||||
@@ -231,6 +340,8 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
|
||||
{hasPrompt ? openAction : null}
|
||||
{newChatAction}
|
||||
{snipAction}
|
||||
{openSnipAction}
|
||||
<Action
|
||||
title="Delete Conversation"
|
||||
icon={Icon.Trash}
|
||||
@@ -244,13 +355,13 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
);
|
||||
};
|
||||
|
||||
if (!resumed || summaries === undefined) {
|
||||
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 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
|
||||
@@ -262,11 +373,13 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
// 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 : "Ask Lam anything…"}
|
||||
searchBarPlaceholder={chat.isStreaming ? placeholder : idlePlaceholder}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : sendAction}
|
||||
{newChatAction}
|
||||
{snipAction}
|
||||
{openSnipAction}
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user