Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,16 +295,20 @@ export function registerAgentIpc({
Boolean(req.attachments?.length),
)
: null;
// The inline text the user's draft carried decides both what the message
// shows and where each image block sits in the prompt.
const steerContent = mcpExpansion?.expanded ?? req.content;
const prepared = await preparePromptAttachments(
dataDir, req.sessionId, context.projectPath, req.attachments ?? [], context.supportsVision,
dataDir, req.sessionId, context.projectPath, req.attachments ?? [],
context.supportsVision, steerContent,
);
const session = await host.call<{ session?: { messages?: UiMessage[] } }>("session.get", {
id: req.sessionId, messageLimit: 1,
});
const message: UiMessage = {
id: durableUserMessageId(req.messageId, session.session?.messages ?? []),
role: "user",
content: mcpExpansion?.expanded ?? req.content,
content: steerContent,
...(mcpExpansion ? { command: mcpExpansion.command } : {}),
status: "complete",
createdAt: new Date().toISOString(),
Expand All @@ -316,11 +320,12 @@ export function registerAgentIpc({
// never turn into a normal prompt or alter the next turn's configuration.
return sidecar.call<{ accepted: boolean; turnId: string }>("agent.steer", {
sessionId: req.sessionId, expectedTurnId: req.expectedTurnId, message,
content: appendPromptFallbackPaths(mcpExpansion?.expanded ?? req.content, prepared),
content: appendPromptFallbackPaths(steerContent, prepared),
...(mcpExpansion ? { mcpServerIds: mcpExpansion.mcpServerIds, mcpToolNames: mcpExpansion.mcpToolNames } : {}),
attachments: prepared.filter((attachment) => attachment.inlineData).map((attachment) => ({
path: attachment.message.ref, name: attachment.message.name, kind: attachment.message.kind,
mimeType: attachment.message.mimeType, size: attachment.message.size, data: attachment.inlineData,
...(attachment.message.inlinePath ? { inlinePath: attachment.message.inlinePath } : {}),
})),
});
});
Expand Down Expand Up @@ -560,6 +565,9 @@ export function registerAgentIpc({
: undefined,
req.attachments ?? [],
supportsVision,
// The text the durable message will hold: an image chip the Composer
// left inline keeps its place in the prompt and in the transcript.
promptContent,
);
} catch (error) {
await finishTurn(req.sessionId, "error", (error as any)?.errorCode, {
Expand Down Expand Up @@ -677,6 +685,9 @@ export function registerAgentIpc({
mimeType: attachment.message.mimeType,
size: attachment.message.size,
data: attachment.inlineData,
...(attachment.message.inlinePath
? { inlinePath: attachment.message.inlinePath }
: {}),
})),
// A referenced conversation crosses the sidecar as quoted text for
// this turn; the durable record above keeps it for later turns.
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/electron/main/prompt-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { isAbsolute, join, relative, resolve } from "node:path";
import {
ErrorCodes,
formatFileInsert,
formatPromptPathText,
isSvgAttachment,
locateInlinePromptPaths,
SVG_MIME_TYPE,
MAX_INLINE_IMAGE_BYTES,
type AgentPromptAttachment,
Expand Down Expand Up @@ -228,9 +230,21 @@ export async function preparePromptAttachments(
projectPath: string | undefined,
attachments: readonly AgentPromptAttachment[],
supportsVision: boolean,
/**
* The prompt text this input carries, with the attachment chips the Composer
* left inline. An image named there keeps that text on the durable message and
* in the prompt, instead of being appended after the body.
*/
promptContent: string,
): Promise<PreparedPromptAttachment[]> {
const prepared: PreparedPromptAttachment[] = [];
for (const attachment of attachments) {
// Reference order is the user's order, so each entry takes the next matching
// `@path` text the prompt holds.
const inlineSpans = locateInlinePromptPaths(
promptContent,
attachments.map((attachment) => attachment.path),
);
for (const [index, attachment] of attachments.entries()) {
const source = resolvePromptPath(dataRoot, sessionId, projectPath, attachment.path);
if (!source) {
throw Object.assign(new Error(`Attachment path is outside the session roots: ${attachment.path}`), {
Expand Down Expand Up @@ -265,6 +279,12 @@ export async function preparePromptAttachments(
: bytes
? ensureAttachmentBlob(dataRoot, bytes)
: await ensureAttachmentBlobFromFile(dataRoot, source.absolute);
// The user's own position for this image: the draft named its path between
// words, so the text stays in the prompt and the transcript renders the
// image there.
const inlinePath = inlineSpans[index]
? formatPromptPathText(attachment.path)
: undefined;
const fallbackPath = inline
? displayPromptPath(source, projectPath)
: await fallbackPathForStoredAttachment(
Expand All @@ -280,6 +300,7 @@ export async function preparePromptAttachments(
ref,
mimeType,
size,
...(inlinePath ? { inlinePath } : {}),
},
fallbackPath,
...(bytes
Expand Down Expand Up @@ -318,6 +339,12 @@ export function appendPromptFallbackPaths(
): string {
const paths = attachments
.filter((attachment) => !attachment.inlineData)
// A path the prompt already names at the user's own position needs no second
// copy at the end; a rewritten fallback (a replayed copy) still travels.
.filter(
(attachment) =>
attachment.message.inlinePath !== formatPromptPathText(attachment.fallbackPath),
)
.map((attachment) => formatFileInsert(attachment.fallbackPath, "file"))
.join("")
.trim();
Expand Down
19 changes: 7 additions & 12 deletions apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
resolveSlashDispatch,
} from "../slash-dispatch";
import { readEditorValue, setEditorCaret, type ComposerFileReference } from "../editor";
import { detachImageTokens } from "../image-attachments";
import type { ComposerDraftSnapshot } from "../../../../lib/composer-smart-stop";
import type { ComposerDraftController } from "./useComposerDraft";

Expand Down Expand Up @@ -200,14 +199,11 @@ export function useComposerSubmit({

const submit = async (steering = false) => {
const rawText = draft.ref.current ? readEditorValue(draft.ref.current) : value;
// Images stay inline chips while editing; the model still receives them as
// the structured attachment, so their tokens leave the prompt text here.
const outgoing = detachImageTokens(rawText, activeFileReferences, 0);
const inlineContent = serializeInlineComposerFileReferences(
outgoing.text,
outgoing.references,
);
const serializedContent = serializeComposerFileReferences(outgoing.text, outgoing.references);
// An image chip keeps its place in the prompt: main resolves the `@path` it
// serializes to against the attachment it prepared, so the image block
// arrives where the user put it instead of trailing the text.
const inlineContent = serializeInlineComposerFileReferences(rawText, activeFileReferences);
const serializedContent = serializeComposerFileReferences(rawText, activeFileReferences);
if (!serializedContent) return;
if (sendBlocked) {
if (pasting) showToast(t("chat.pasteInProgress"), { variant: "info" });
Expand Down Expand Up @@ -262,11 +258,10 @@ export function useComposerSubmit({
visibleCommandEnd === -1
? ""
: visibleDraft.slice(visibleCommandEnd).trim();
const outgoingBody = detachImageTokens(visibleCommandBody, activeFileReferences, 0);
const accepted = await sendPrompt(
serializeInlineComposerFileReferences(
outgoingBody.text,
outgoingBody.references,
visibleCommandBody,
activeFileReferences,
),
draft.draftSnapshot(visibleCommandBody),
activeSessionId ?? undefined,
Expand Down
22 changes: 0 additions & 22 deletions apps/desktop/src/features/chat/composer/image-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,6 @@ export function isImageReference(reference: ComposerFileReference): boolean {
return reference.kind === "image" || Boolean(reference.mimeType?.toLowerCase().startsWith("image/"));
}

/**
* Keep images outside the submitted prompt text. Images stay inline chips while
* the draft is being edited, so the text handed to the model is detached only
* at submission time, where the structured reference already carries the image.
*/
export function detachImageTokens(text: string, references: ComposerFileReference[], caret: number) {
let nextText = text;
let nextCaret = caret;
const nextReferences = references.map((reference) => {
if (!isImageReference(reference) || !reference.token) return reference;
const { token, ...attachment } = reference;
let index = nextText.indexOf(token);
while (index !== -1) {
if (index < nextCaret) nextCaret -= Math.min(token.length, nextCaret - index);
nextText = nextText.slice(0, index) + nextText.slice(index + token.length);
index = nextText.indexOf(token);
}
return attachment;
});
return { text: nextText, references: nextReferences, caret: nextCaret };
}

/**
* Keep images visible as inline chips when a draft enters the editor. A draft
* cached before images became chips, a restored queue entry, or a prefill can
Expand Down
62 changes: 48 additions & 14 deletions apps/desktop/src/features/chat/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import type { UiMessage } from "@pi-desktop/shared";
import { splitInlineContent } from "@pi-desktop/shared";
import { useOpenChatFileRef } from "../../../hooks/use-preview-target";
import { useAppStore } from "../../../stores/app-store";
import { Markdown } from "../../../components/Markdown";
Expand Down Expand Up @@ -115,6 +116,32 @@ export const MessageRow = memo(function MessageRow({
workspaceRoot,
);
}, [message.attachments, message.content, workspaceRoot]);
// The user's own order: an image chip the draft named inline renders between
// the text runs it sat between, exactly where the Composer showed it.
const userContentParts = useMemo(
() => splitInlineContent(String(message.content || ""), message.attachments ?? []).parts,
[message.attachments, message.content],
);
const renderContentPart = (
part: (typeof userContentParts)[number],
index: number,
keyPrefix: string,
) =>
part.kind === "text" ? (
<LinkifiedText
key={`${keyPrefix}-${index}`}
text={part.text}
attachments={message.attachments}
sourceOffset={part.start}
/>
) : (
<MessageAttachmentImage
key={`${keyPrefix}-${index}`}
attachment={part.attachment}
onOpenFile={openFileRef}
inline
/>
);
// An attachment the body does not already name inline continues the body
// text instead of taking a line of its own above it.
const attachmentChips = extraAttachments.length ? (
Expand Down Expand Up @@ -280,21 +307,28 @@ export const MessageRow = memo(function MessageRow({
{message.content ? (
<div className="message-user-text selectable">
{editableUserMessage && message.command ? (
message.skillMentions?.length ? (
<SkillInvocationText message={message} />
) : (
// Templates retain the existing whole-invocation chip.
<code
className="chat-command-chip"
data-source-start={0}
data-source-end={message.content.length}
title={String(message.content || "")}
>
{message.command}
</code>
)
<>
{message.skillMentions?.length ? (
<SkillInvocationText message={message} />
) : (
// Templates retain the existing whole-invocation chip.
<code
className="chat-command-chip"
data-source-start={0}
data-source-end={message.content.length}
title={String(message.content || "")}
>
{message.command}
</code>
)}
{userContentParts
.filter((part) => part.kind === "attachment")
.map((part, index) => renderContentPart(part, index, "inline"))}
</>
) : (
<LinkifiedText text={String(message.content || "")} attachments={message.attachments} />
userContentParts.map((part, index) =>
renderContentPart(part, index, "content"),
)
)}
{attachmentChips}
</div>
Expand Down
15 changes: 10 additions & 5 deletions apps/desktop/src/features/chat/transcript/extra-attachments.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import type { MessageAttachment } from "@pi-desktop/shared";
import { isRenderableAttachment, splitInlineContent, type MessageAttachment } from "@pi-desktop/shared";
import { splitChatText } from "../../../lib/chat-links.ts";

/** Attachments already represented by a verified inline file link stay inline. */
/**
* Attachments that still need their own place in the message: the ones already
* represented by a verified inline file link, and the ones the body names at
* their inline position (`inlinePath`), stay where the user put them.
*/
export function getExtraMessageAttachments(
content: string,
attachments: readonly MessageAttachment[] | undefined,
workspaceRoot?: string | null,
): (MessageAttachment & { kind: "file" | "image" })[] {
if (!attachments?.length) return [];
const renderable = attachments.filter(isRenderableAttachment);
if (!renderable.length) return [];
const inline = new Set(
splitChatText(content, workspaceRoot)
.map((segment) => segment.kind === "target" && segment.target.kind === "file"
? segment.target.path
: null)
.filter((path): path is string => path !== null),
);
return attachments.filter(
(attachment): attachment is MessageAttachment & { kind: "file" | "image" } =>
attachment.kind !== "session" && !inline.has(attachment.ref),
return splitInlineContent(content, renderable).trailing.filter(
(attachment) => !inline.has(attachment.ref),
);
}
20 changes: 16 additions & 4 deletions apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,12 @@ export function SessionLinkChip({ sessionId, ...position }: { sessionId: string
export function MessageAttachmentImage({
attachment,
onOpenFile,
inline = false,
}: {
attachment: MessageAttachment;
onOpenFile: (path: string, baseDir?: string, mimeType?: string) => void;
/** Inline placement inside the message prose; only the trailing list has a list parent. */
inline?: boolean;
}) {
const dataUrl = useReferencedImageDataUrl(attachment.ref, attachment.mimeType);
const [anchor, setAnchor] = useState<ImageHoverAnchor | null>(null);
Expand All @@ -554,7 +557,7 @@ export function MessageAttachmentImage({
<span
ref={chipRef}
className="message-attachment-image-chip"
role="listitem"
role={inline ? undefined : "listitem"}
onPointerEnter={reveal}
onPointerLeave={dismiss}
onFocus={reveal}
Expand All @@ -574,7 +577,16 @@ export function MessageAttachmentImage({
}

/** Plain user text: @paths become composer-like chips; URLs stay text links. */
export function LinkifiedText({ text, attachments }: { text: string; attachments?: readonly MessageAttachment[] }) {
export function LinkifiedText({
text,
attachments,
sourceOffset = 0,
}: {
text: string;
attachments?: readonly MessageAttachment[];
/** Where this run starts in the message content, so offsets stay absolute. */
sourceOffset?: number;
}) {
const { t } = useTranslation();
const openTarget = useOpenPreviewTarget();
const openFileRef = useOpenChatFileRef();
Expand All @@ -583,9 +595,9 @@ export function LinkifiedText({ text, attachments }: { text: string; attachments
return (
<>
{segments.map((segment, index) => {
const start = offset;
const start = sourceOffset + offset;
offset += segment.text.length;
const position = { "data-source-start": start, "data-source-end": offset };
const position = { "data-source-start": start, "data-source-end": sourceOffset + offset };
return segment.kind === "text" ? (
<span key={index} {...position}>{segment.text}</span>
) : segment.target.kind === "file" ? (
Expand Down
Loading
Loading