Skip to content
Open
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
30 changes: 29 additions & 1 deletion components/ChatInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const jiti = createJiti(import.meta.url, {
jsx: { runtime: "automatic" },
tsconfigPaths: true,
});
const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, filterModelOptions } = await jiti.import("./ChatInput.tsx");
const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, filterModelOptions, getUserMessageText, getUserMessageDraftImages } = await jiti.import("./ChatInput.tsx");
const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx");

test("renders the upstream model error", () => {
Expand Down Expand Up @@ -75,6 +75,34 @@ test("filters model options by name and id", () => {
assert.equal(filterModelOptions(options, " "), options);
});

test("restores text and base64 images when editing a user message", () => {
const message = {
role: "user",
content: [
{ type: "text", text: "Review this image @src/example.ts " },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AQID" } },
],
};

assert.equal(getUserMessageText(message), "Review this image @src/example.ts ");
assert.deepEqual(getUserMessageDraftImages(message), [
{ data: "AQID", mimeType: "image/png" },
]);
});

test("restores legacy flat image entries when editing a user message", () => {
const message = {
role: "user",
content: [
{ type: "image", data: "AQID", mimeType: "image/jpeg" },
],
};

assert.deepEqual(getUserMessageDraftImages(message), [
{ data: "AQID", mimeType: "image/jpeg" },
]);
});

test("renders compact errors above the input as a wrapping alert", () => {
const error = "Compaction failed: OpenAI API error (403): <html>request forbidden</html>";
const html = renderToStaticMarkup(
Expand Down
45 changes: 45 additions & 0 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react";
import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession";
import type { SkillsResponse } from "@/lib/api-types";
import type { TextContent, UserMessage } from "@/lib/types";
import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store";
import {
MAX_ATTACHED_IMAGE_BYTES,
Expand Down Expand Up @@ -74,6 +75,7 @@ interface Props {
export interface ChatInputHandle {
insertText: (text: string) => void;
insertIfEmpty: (text: string) => void;
replaceMessage: (message: UserMessage) => void;
prependText: (text: string) => void;
addImages: (files: File[]) => void;
}
Expand Down Expand Up @@ -211,6 +213,30 @@ function draftImagesToAttachedImages(images: ChatDraftImage[] | undefined): Atta
.map(draftImageToAttachedImage);
}

export function getUserMessageText(message: UserMessage): string {
if (typeof message.content === "string") return message.content;
return message.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}

export function getUserMessageDraftImages(message: UserMessage): ChatDraftImage[] {
if (typeof message.content === "string") return [];
return message.content.flatMap((block) => {
if (block.type !== "image") return [];

// Support both the current nested image format and older flat pi-ai entries.
const flat = block as unknown as { data?: unknown; mimeType?: unknown };
const data = block.source?.type === "base64" ? block.source.data : flat.data;
const mimeType = block.source?.type === "base64" ? block.source.media_type : flat.mimeType;
if (typeof data !== "string" || typeof mimeType !== "string") return [];

const image = { data, mimeType };
return isBase64ImageWithinLimits(image) ? [image] : [];
});
}

function revokeImagePreview(image: AttachedImage): void {
if (image.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(image.previewUrl);
Expand Down Expand Up @@ -393,6 +419,25 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
});
},
replaceMessage(message: UserMessage) {
const ta = textareaRef.current;
const current = ta ? ta.value : value;
if (current.trim() || attachedImagesRef.current.length > 0) return;

setValue(getUserMessageText(message));
setAtQuery(null);
setHistoryMenuOpen(false);
setAttachedImages((prev) => {
prev.forEach(revokeImagePreview);
return draftImagesToAttachedImages(getUserMessageDraftImages(message));
});
requestAnimationFrame(() => {
if (!ta) return;
ta.focus();
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
});
},
prependText(text: string) {
if (!text.trim()) return;
const ta = textareaRef.current;
Expand Down
6 changes: 3 additions & 3 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";
import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage } from "@/lib/types";
import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types";
import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi";
import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display";
Expand Down Expand Up @@ -192,8 +192,8 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
}, [onAgentEnd]);

// 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染
const handleEditContent = useCallback((content: string) => {
chatInputRef?.current?.insertIfEmpty(content);
const handleEditContent = useCallback((message: UserMessage) => {
chatInputRef?.current?.replaceMessage(message);
}, [chatInputRef]);

const {
Expand Down
6 changes: 3 additions & 3 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ interface Props {
forking?: boolean;
onNavigate?: (entryId: string) => void;
prevAssistantEntryId?: string;
onEditContent?: (content: string) => void;
onEditContent?: (message: UserMessage) => void;
showTimestamp?: boolean;
prevTimestamp?: number;
sessionId?: string;
Expand Down Expand Up @@ -146,7 +146,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
forking?: boolean;
onNavigate?: (entryId: string) => void;
prevAssistantEntryId?: string;
onEditContent?: (content: string) => void;
onEditContent?: (message: UserMessage) => void;
}) {
const { t } = useI18n();
const [hovered, setHovered] = useState(false);
Expand Down Expand Up @@ -278,7 +278,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
}}>
{canNavigate && (
<button
onClick={() => { onNavigate!(prevAssistantEntryId!); onEditContent?.(content); }}
onClick={() => { onNavigate!(prevAssistantEntryId!); onEditContent?.(message); }}
title={t("i18n.editFromHereTitle")}
style={{
display: "flex", alignItems: "center", gap: 4,
Expand Down