fix: take the launcher window out of the shot when snipping
The snip was selecting a region around the launcher panel sitting in the middle of the screen. `activateEntrypoint` calls `showWindow()` when it activates a command from a closed launcher, so a view command's window is already up before any extension code runs. Capturing from inside the chat view could never have worked. - `snip` is now a `no-view` command. It calls `closeMainWindow()` first, waits out the 50ms hide that `ui-service.hpp` schedules, then captures with nothing on screen, then brings the window back by launching `chat` with the file. `settleWindow()` exists because a capture started the instant the close resolves could still find the window there. - The chat's action no longer captures inline. It launches `snip` and passes the session id, so there is one capture path, one capability check, and one place that deals with the window. - The hand-off relays through `snip` rather than going straight back to `chat` on purpose: `ActivateEntrypointOptions::toggleIfAlreadyActive` defaults to true, so launching the *already active* view command closes the window instead of reopening it. A launch cannot override that flag, but relaying through a different no-view command makes the toggle branch unreachable. - The action is hidden while streaming, since the hand-off unloads the view and would abandon an answer mid-sentence. - Vision is settled before the selector appears, so a model that cannot read images never costs a selection gesture. `no` and `unknown` get different HUD wording. - `lib/snip.ts` holds the hand-off contract; `lib/hooks/useSnip.ts` is gone, as nothing captures from a view any more. Verified: 252 tests, tsc, eslint, prettier, and vici build. The deployed manifest now reports `snip mode=no-view`.
This commit is contained in:
+101
-104
@@ -1,14 +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 { useSnip } from "../hooks/useSnip";
|
||||
import { useVisionSupport } from "../hooks/useVision";
|
||||
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { buildThread } from "../thread";
|
||||
import type { PendingAttachment, Session, SessionSummary } from "../types";
|
||||
import { recencyGroup, truncate } from "../utils";
|
||||
import { errorMessage, recencyGroup, truncate } from "../utils";
|
||||
|
||||
/**
|
||||
* The chat surface: conversations on the left, the open conversation on the right.
|
||||
@@ -47,6 +47,9 @@ 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.";
|
||||
@@ -54,7 +57,7 @@ const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search
|
||||
/** 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 captured but not yet sent. */
|
||||
/** The reminder shown while a snip is attached but not yet sent. */
|
||||
const PENDING_NOTE = "_Screenshot attached. Ask a question about it, or press Return to send it as it is._";
|
||||
|
||||
export interface ChatViewProps {
|
||||
@@ -62,10 +65,12 @@ export interface ChatViewProps {
|
||||
initialSession?: Session;
|
||||
/** Text handed over by a deeplink or fallback command. */
|
||||
initialPrompt?: string;
|
||||
/** A snip that has already been captured, used when a view is pushed with one. */
|
||||
initialAttachment?: PendingAttachment;
|
||||
/** Captures a snip on mount, which is what the standalone command does. */
|
||||
startWithSnip?: boolean;
|
||||
/** 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 {
|
||||
@@ -75,27 +80,28 @@ interface SessionRow {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function ChatView({ initialSession, initialPrompt, initialAttachment, startWithSnip }: ChatViewProps) {
|
||||
export function ChatView({
|
||||
initialSession,
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
initialAttachmentPath,
|
||||
launchNote,
|
||||
}: ChatViewProps) {
|
||||
const preferences = useMemo(() => getLamPreferences(), []);
|
||||
const chat = useChat({ preferences, initialSession });
|
||||
const { replaceSession } = chat;
|
||||
const { isSniping, snip } = useSnip();
|
||||
const { canSeeImages } = useVisionSupport(preferences);
|
||||
|
||||
const {
|
||||
support: vision,
|
||||
canSeeImages,
|
||||
isChecking: isCheckingVision,
|
||||
recheck,
|
||||
} = useVisionSupport(preferences);
|
||||
/** A launch that hands something over has to wait for it before it can render. */
|
||||
const needsPreparation = Boolean(initialSessionId) || Boolean(initialAttachmentPath);
|
||||
|
||||
const [input, setInput] = useState(initialPrompt ?? "");
|
||||
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
|
||||
// A snip command starts a new chat on purpose, so the idle-resume below is
|
||||
// skipped and the view must not wait for it.
|
||||
const [resumed, setResumed] = useState(Boolean(initialSession) || Boolean(startWithSnip));
|
||||
const [prepared, setPrepared] = useState(!needsPreparation);
|
||||
const [resumed, setResumed] = useState(Boolean(initialSession));
|
||||
// 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>(initialAttachment);
|
||||
const [pending, setPending] = useState<PendingAttachment | undefined>(undefined);
|
||||
const [snipNote, setSnipNote] = useState<string | undefined>(undefined);
|
||||
|
||||
const activeId = chat.session.id;
|
||||
@@ -111,18 +117,55 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
reload();
|
||||
}, [reload, chat.session.updatedAt]);
|
||||
|
||||
/**
|
||||
* Restores whatever the launching command handed over.
|
||||
*
|
||||
* This is the second half of the snip handoff: the capture command closed the
|
||||
* window to take the shot, so it comes back here with a file to read and, when
|
||||
* the snip was started from a conversation, the id of the conversation to
|
||||
* return to. Reloading by id rather than trusting "most recent" matters
|
||||
* because the snip itself is not a turn, so nothing was saved in between.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!needsPreparation) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
if (initialSessionId) {
|
||||
const session = await loadSession(initialSessionId);
|
||||
if (!cancelled && session) replaceSession(session);
|
||||
}
|
||||
|
||||
if (initialAttachmentPath) {
|
||||
try {
|
||||
const attachment = await readAttachment(initialAttachmentPath);
|
||||
if (!cancelled) setPending(attachment);
|
||||
} catch (thrown) {
|
||||
if (!cancelled) setSnipNote(`**Could not open the screenshot:** ${errorMessage(thrown)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setPrepared(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsPreparation, initialSessionId, initialAttachmentPath, replaceSession]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The snip command opts out entirely: it exists to start a new chat about one
|
||||
* screenshot, and quietly continuing the previous conversation would bury the
|
||||
* image in an unrelated thread.
|
||||
* A hand-off launch opts out: it either names the conversation to return to or
|
||||
* deliberately starts a new one, and quietly continuing some other chat would
|
||||
* bury the screenshot in an unrelated thread.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialSession || startWithSnip) return;
|
||||
if (initialSession || needsPreparation) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -144,7 +187,7 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSession, startWithSnip, preferences, replaceSession]);
|
||||
}, [initialSession, needsPreparation, preferences, replaceSession]);
|
||||
|
||||
const thread = useMemo(
|
||||
() =>
|
||||
@@ -170,37 +213,15 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
* already looking.
|
||||
*/
|
||||
const threadMarkdown = useMemo(() => {
|
||||
const notes: string[] = [];
|
||||
|
||||
// Only the standalone command has to explain a model that cannot see; inside
|
||||
// the chat the action is simply absent. A failed check is worded separately
|
||||
// from a text-only model, because saying "your model cannot see" when the
|
||||
// request merely failed is a claim about the user's setup that is not true.
|
||||
if (startWithSnip && !isCheckingVision && vision !== "yes") {
|
||||
notes.push(
|
||||
vision === "no"
|
||||
? `**${preferences.model} cannot see images.** Choose a model with vision support in preferences, and this command becomes a one-key snip.`
|
||||
: `**Could not check whether ${preferences.model} can see images.** Use Check Again below, or snip from the chat's action panel once the connection is back.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (snipNote) notes.push(snipNote);
|
||||
if (pending) notes.push(PENDING_NOTE);
|
||||
const notes = [launchNote, snipNote, pending ? PENDING_NOTE : undefined].filter(
|
||||
(note): note is string => Boolean(note),
|
||||
);
|
||||
|
||||
const transcript =
|
||||
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions);
|
||||
|
||||
return [...notes, transcript].join("\n\n");
|
||||
}, [
|
||||
startWithSnip,
|
||||
isCheckingVision,
|
||||
vision,
|
||||
preferences.model,
|
||||
snipNote,
|
||||
pending,
|
||||
thread,
|
||||
transcriptOptions,
|
||||
]);
|
||||
}, [launchNote, snipNote, pending, thread, transcriptOptions]);
|
||||
|
||||
const rows: SessionRow[] = useMemo(() => {
|
||||
const base = summaries ?? [];
|
||||
@@ -259,30 +280,6 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
[activeId, chat, reload],
|
||||
);
|
||||
|
||||
/**
|
||||
* Captures a region and holds it until a question is sent with it.
|
||||
*
|
||||
* A cancelled selection deliberately says nothing: the user pressed Escape and
|
||||
* telling them so is noise. A failed capture has to explain itself, because
|
||||
* the usual cause is a capture tool that is not installed.
|
||||
*/
|
||||
const takeSnip = useCallback(async () => {
|
||||
setSnipNote(undefined);
|
||||
const outcome = await snip();
|
||||
|
||||
if (outcome.status === "captured") setPending(outcome.attachment);
|
||||
else if (outcome.status === "failed")
|
||||
setSnipNote(`**Could not take a screenshot:** ${outcome.message}`);
|
||||
}, [snip]);
|
||||
|
||||
// The standalone command captures on arrival. Waiting for the capability check
|
||||
// matters here: capturing a screenshot only to be told the model cannot read
|
||||
// it would throw away the selection the user just made.
|
||||
useEffect(() => {
|
||||
if (!startWithSnip || !canSeeImages || initialAttachment) return;
|
||||
void takeSnip();
|
||||
}, [startWithSnip, canSeeImages, initialAttachment, takeSnip]);
|
||||
|
||||
/** The snip a "show me the screenshot" action would open: the pending one, else the newest. */
|
||||
const latestAttachment =
|
||||
pending ??
|
||||
@@ -320,20 +317,33 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
);
|
||||
|
||||
/**
|
||||
* Present only when Ollama has confirmed the model reports `vision`.
|
||||
* Hands the capture to the `snip` command instead of doing it here.
|
||||
*
|
||||
* Offering it otherwise would let the user frame a careful screenshot and
|
||||
* then have a model that cannot see answer as though it had, which is a worse
|
||||
* failure than the action not being there.
|
||||
* 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 ? (
|
||||
<Action
|
||||
title="Snip a Region"
|
||||
icon={Icon.Screenshot}
|
||||
shortcut={{ key: "s", modifiers: ["cmd", "shift"] }}
|
||||
onAction={() => void takeSnip()}
|
||||
/>
|
||||
) : null;
|
||||
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.
|
||||
@@ -341,13 +351,6 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
<Action.Open title="Open Screenshot" icon={Icon.Image} target={latestAttachment.path} />
|
||||
) : null;
|
||||
|
||||
// Only the standalone command reaches a state with nowhere to go: a failed
|
||||
// capability check leaves it with no snip and no action that would start one.
|
||||
const checkAgainAction =
|
||||
startWithSnip && !isCheckingVision && vision === "unknown" ? (
|
||||
<Action title="Check Again" icon={Icon.ArrowClockwise} onAction={recheck} />
|
||||
) : 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
|
||||
@@ -366,7 +369,6 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
{newChatAction}
|
||||
{snipAction}
|
||||
{openSnipAction}
|
||||
{checkAgainAction}
|
||||
<Action
|
||||
title="Delete Conversation"
|
||||
icon={Icon.Trash}
|
||||
@@ -380,12 +382,10 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
);
|
||||
};
|
||||
|
||||
if (!resumed || summaries === undefined) {
|
||||
if (!resumed || !prepared || summaries === undefined) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
}
|
||||
|
||||
const busy = chat.isStreaming || isSniping;
|
||||
|
||||
const placeholder = chat.status
|
||||
? `${chat.status}. Press ⏎ to stop`
|
||||
: "Lam is thinking. Press ⏎ to stop";
|
||||
@@ -394,7 +394,7 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
|
||||
return (
|
||||
<List
|
||||
isLoading={busy}
|
||||
isLoading={chat.isStreaming}
|
||||
isShowingDetail
|
||||
filtering={false}
|
||||
searchText={input}
|
||||
@@ -402,16 +402,13 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
// 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 : isSniping ? "Select a region…" : idlePlaceholder
|
||||
}
|
||||
searchBarPlaceholder={chat.isStreaming ? placeholder : idlePlaceholder}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{chat.isStreaming ? stopAction : sendAction}
|
||||
{newChatAction}
|
||||
{snipAction}
|
||||
{openSnipAction}
|
||||
{checkAgainAction}
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
@@ -426,7 +423,7 @@ export function ChatView({ initialSession, initialPrompt, initialAttachment, sta
|
||||
// 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={busy} />}
|
||||
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={chat.isStreaming} />}
|
||||
actions={rowActions(row)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user