feat: render the attached screenshot instead of just naming it

The transcript said "_Screenshot attached_" and left it at that, on a note in
the test file claiming Vicinae could not resolve a local image. It can. That
claim was true of a URL the model invented, not of a file this extension
captured itself.

Checked against the renderer rather than assumed:

- `imageProviderUrl` in `markdown-model.cpp` routes `file://`, and a
  scheme-less string, through `ImageURL::local`. Anything it does not
  recognise comes back empty, which is why a bare path is not good enough.
- `MdImage.qml` draws it block-level and centred, using the alt text as an
  italic caption, and caps the rendered height at 200px regardless of the
  `raycast-width`/`raycast-height` hint it also accepts.

So `attachments.ts` gains `imageSource`, which builds a `file://` URL with
`pathToFileURL` so a path with a space or non-ascii still resolves, and
`markdown.ts` gains `renderAttachments`, used for a turn's attachments and
for an unsent pending snip. That second use is the one that matters most:
the capture is now visible before the question about it exists.

The alt text carries the label, so a deleted file degrades to what the
transcript used to say. The 200px cap is a Vicinae limit, so the action panel
keeps Open Screenshot for the full image.

Tests that asserted the old behaviour are inverted, and imageSource is
covered for spaces and non-ascii.

Verified: 280 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
2026-10-04 15:37:14 +08:00
parent e00b49bbe5
commit c1212192c5
6 changed files with 109 additions and 23 deletions
+9 -5
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { readAttachment } from "../attachments";
import { useChat } from "../hooks/useChat";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { resolveStartup } from "../startup";
@@ -59,7 +59,7 @@ const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search
const DEFAULT_IMAGE_PROMPT = "What is in this image?";
/** 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._";
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. */
@@ -182,9 +182,13 @@ export function ChatView({
* already looking.
*/
const threadMarkdown = useMemo(() => {
const notes = [launchNote, snipNote, pending ? PENDING_NOTE : undefined].filter((note): note is string =>
Boolean(note),
);
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));
const transcript =
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions);