Skip to content

Commit 3d49a32

Browse files
committed
feat(chat): 支持消息编辑功能并实现编辑状态恢复
- 在Messages组件中添加编辑消息功能的回调传递和触发逻辑 - 在ChatProvider中新增editingMessage状态及对应的reducer和actions - InputPrompt组件实现编辑消息文本和附件的恢复加载 - 发送消息时若处于编辑状态,触发清除编辑状态的回调 - PromptAttachments支持从图片URL恢复附件功能,防止重复加载 - 添加编辑状态相关的单元测试验证编辑文本、附件加载及发送行为 - 更新类型定义,新增EditingMessage接口及编辑状态相关类型声明
1 parent 384dc39 commit 3d49a32

8 files changed

Lines changed: 187 additions & 9 deletions

File tree

packages/vscode-ide-companion/src/webview/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default function App() {
3232
loading={state.loading}
3333
llmStreamProgress={state.llmStreamProgress}
3434
processes={state.processes}
35+
onEditMessage={actions.editMessage}
3536
/>
3637
{state.loading && (
3738
<ThinkingLiveBubble
@@ -49,9 +50,11 @@ export default function App() {
4950
activeSessionStatus={state.activeSessionStatus}
5051
tokenTelemetry={state.tokenTelemetry}
5152
activeEditor={state.activeEditor}
53+
editingMessage={state.editingMessage}
5254
onSendPrompt={actions.sendPrompt}
5355
onInterrupt={actions.interrupt}
5456
onSelectSkills={actions.setSelectedSkills}
57+
onClearEditingMessage={() => actions.editMessage(null)}
5558
/>
5659
</div>
5760
);

packages/vscode-ide-companion/src/webview/components/InputPrompt.test.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import React from "react";
12-
import { render, screen, fireEvent } from "@testing-library/react";
12+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
1313
import { describe, it, expect, vi, beforeEach } from "vitest";
1414
import InputPrompt, { type InputPromptProps } from "./InputPrompt";
1515
import type { SkillInfo } from "@/webview/types";
@@ -44,8 +44,8 @@ vi.mock("@/webview/components/ui/button", () => ({
4444
vi.mock("@/webview/components/ui/input-group", () => ({
4545
InputGroup: vi.fn(({ children }) => <div data-testid="input-group">{children}</div>),
4646
InputGroupAddon: vi.fn(({ children }) => <div data-testid="input-group-addon">{children}</div>),
47-
InputGroupButton: vi.fn(({ children, onClick }) => (
48-
<button data-testid="input-group-button" onClick={onClick}>
47+
InputGroupButton: vi.fn(({ children, onClick, title, disabled }) => (
48+
<button data-testid="input-group-button" onClick={onClick} title={title} disabled={disabled}>
4949
{children}
5050
</button>
5151
)),
@@ -127,6 +127,7 @@ const mockOnSendPrompt =
127127
>();
128128
const mockOnInterrupt = vi.fn<() => void>();
129129
const mockOnSelectSkills = vi.fn<(skills: SkillInfo[]) => void>();
130+
const mockOnClearEditingMessage = vi.fn<() => void>();
130131

131132
const defaultProps: InputPromptProps = {
132133
loading: false,
@@ -144,9 +145,11 @@ const defaultProps: InputPromptProps = {
144145
usage: null,
145146
},
146147
activeEditor: null,
148+
editingMessage: null,
147149
onSendPrompt: mockOnSendPrompt,
148150
onInterrupt: mockOnInterrupt,
149151
onSelectSkills: mockOnSelectSkills,
152+
onClearEditingMessage: mockOnClearEditingMessage,
150153
};
151154

152155
describe("InputPrompt", () => {
@@ -257,4 +260,46 @@ describe("InputPrompt", () => {
257260
expect(screen.getByTestId("hover-card")).toBeInTheDocument();
258261
});
259262
});
263+
264+
describe("Editing message", () => {
265+
it("restores text when editingMessage changes", () => {
266+
const { rerender } = render(<InputPrompt {...defaultProps} />);
267+
268+
expect(screen.getByRole("textbox").value).toBe("");
269+
270+
rerender(
271+
<InputPrompt {...defaultProps} editingMessage={{ text: "Editing this message", images: [], skills: [] }} />
272+
);
273+
274+
expect(screen.getByRole("textbox").value).toBe("Editing this message");
275+
});
276+
277+
it("calls onClearEditingMessage when sending while editing", async () => {
278+
const { container } = render(
279+
<InputPrompt {...defaultProps} editingMessage={{ text: "Editing this message", images: [], skills: [] }} />
280+
);
281+
282+
// Wait for the editing message to be processed
283+
await waitFor(() => {
284+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
285+
expect(textarea.value).toBe("Editing this message");
286+
});
287+
288+
// Click send button
289+
const sendButton = container.querySelector("button[title='Send']");
290+
if (sendButton) {
291+
fireEvent.click(sendButton);
292+
}
293+
294+
expect(mockOnSendPrompt).toHaveBeenCalledWith("Editing this message", [], [], undefined);
295+
expect(mockOnClearEditingMessage).toHaveBeenCalled();
296+
});
297+
298+
it("calls onSelectSkills when editingMessage has skills", () => {
299+
const skills = [{ name: "TestSkill" }];
300+
render(<InputPrompt {...defaultProps} editingMessage={{ text: "Test message", images: [], skills }} />);
301+
302+
expect(mockOnSelectSkills).toHaveBeenCalledWith(skills);
303+
});
304+
});
260305
});

packages/vscode-ide-companion/src/webview/components/InputPrompt.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import SkillsPanel from "@/webview/components/SkillsPanel";
33
import SkillsTags from "@/webview/components/SkillsTags";
44
import ContextMeter from "@/webview/components/ContextMeter";
55
import { PromptAttachments, usePromptAttachments } from "@/webview/components/PromptAttachments";
6-
import type { ActiveEditor, SkillInfo, TokenTelemetry } from "@/webview/types";
6+
import type { ActiveEditor, EditingMessage, SkillInfo, TokenTelemetry } from "@/webview/types";
77
import { FileCodeIcon, Send, Square } from "lucide-react";
88
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/webview/components/ui/input-group";
99
import { Separator } from "@/webview/components/ui/separator";
@@ -20,6 +20,7 @@ export interface InputPromptProps {
2020
activeSessionStatus: string | null;
2121
tokenTelemetry?: TokenTelemetry;
2222
activeEditor: ActiveEditor | null;
23+
editingMessage: EditingMessage | null;
2324
onSendPrompt: (
2425
prompt: string,
2526
skills?: SkillInfo[],
@@ -28,6 +29,7 @@ export interface InputPromptProps {
2829
) => void;
2930
onInterrupt: () => void;
3031
onSelectSkills: (skills: SkillInfo[]) => void;
32+
onClearEditingMessage: () => void;
3133
}
3234

3335
export default function InputPrompt({
@@ -39,16 +41,19 @@ export default function InputPrompt({
3941
activeSessionStatus,
4042
tokenTelemetry,
4143
activeEditor,
44+
editingMessage,
4245
onSendPrompt,
4346
onInterrupt,
4447
onSelectSkills,
48+
onClearEditingMessage,
4549
}: InputPromptProps) {
4650
const [value, setValue] = useState("");
4751
const [history, setHistory] = useState<string[]>([]);
4852
const [historyIdx, setHistoryIdx] = useState(-1);
4953
const [draftBeforeHistory, setDraftBeforeHistory] = useState<string>("");
5054
const textareaRef = useRef<HTMLTextAreaElement>(null);
51-
const { attachments, handlePaste, removeAttachment, clearAttachments, getImageUrls } = usePromptAttachments();
55+
const { attachments, handlePaste, removeAttachment, clearAttachments, getImageUrls, loadImages } =
56+
usePromptAttachments();
5257
console.log("askPermissions:", askPermissions);
5358
console.log("activeSessionStatus:", activeSessionStatus);
5459

@@ -69,6 +74,19 @@ export default function InputPrompt({
6974
autoResize();
7075
}, [value, autoResize]);
7176

77+
// Restore editing state when editingMessage changes
78+
useEffect(() => {
79+
if (editingMessage) {
80+
setValue(editingMessage.text);
81+
if (editingMessage.images && editingMessage.images.length > 0) {
82+
loadImages(editingMessage.images);
83+
}
84+
if (editingMessage.skills && editingMessage.skills.length > 0) {
85+
onSelectSkills(editingMessage.skills);
86+
}
87+
}
88+
}, [editingMessage, loadImages, onSelectSkills]);
89+
7290
const handleSend = useCallback(() => {
7391
const trimmed = value.trim();
7492
const images = getImageUrls();
@@ -82,6 +100,7 @@ export default function InputPrompt({
82100
onSendPrompt(trimmed, selectedSkills, images, reply || undefined);
83101
onSelectSkills([]);
84102
clearAttachments();
103+
onClearEditingMessage();
85104
}, [
86105
value,
87106
loading,
@@ -91,6 +110,7 @@ export default function InputPrompt({
91110
onSelectSkills,
92111
getImageUrls,
93112
clearAttachments,
113+
onClearEditingMessage,
94114
]);
95115

96116
const handleKeyDown = useCallback(

packages/vscode-ide-companion/src/webview/components/Messages.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ import ThinkingBubble from "@/webview/components/bubbles/ThinkingBubble";
66
import ToolBubble from "@/webview/components/bubbles/ToolBubble";
77
import SystemBubble from "@/webview/components/bubbles/SystemBubble";
88
import type { SessionMessage } from "@/webview/types";
9+
import type { EditingMessage } from "@/webview/types";
910

1011
interface MessagesProps {
1112
messages: SessionMessage[];
1213
loading: boolean;
1314
llmStreamProgress: unknown;
1415
processes: Record<string, { startTime: string; command: string }> | null;
16+
onEditMessage?: (editing: EditingMessage) => void;
1517
}
1618

17-
export default function Messages({ messages, loading, llmStreamProgress, processes }: MessagesProps) {
19+
export default function Messages({ messages, loading, llmStreamProgress, processes, onEditMessage }: MessagesProps) {
1820
const bottomRef = useRef<HTMLDivElement>(null);
1921

2022
console.log("llmStreamProgress: ", llmStreamProgress);
@@ -34,7 +36,25 @@ export default function Messages({ messages, loading, llmStreamProgress, process
3436
console.log(`Rendering message ${index}:`, JSON.stringify(msg));
3537
switch (msg.role) {
3638
case "user":
37-
return <UserBubble key={`msg-${index}`} content={msg.content} meta={msg.meta} />;
39+
return (
40+
<UserBubble
41+
key={`msg-${index}`}
42+
content={msg.content}
43+
meta={msg.meta}
44+
onEdit={
45+
onEditMessage
46+
? () => {
47+
const meta = msg.meta as { userPrompt?: { imageUrls?: string[] } } | undefined;
48+
onEditMessage({
49+
text: msg.content,
50+
images: meta?.userPrompt?.imageUrls ?? [],
51+
skills: [],
52+
});
53+
}
54+
: undefined
55+
}
56+
/>
57+
);
3858
case "assistant": {
3959
const meta = msg.meta as { asThinking?: boolean } | undefined;
4060
if (meta?.asThinking) {

packages/vscode-ide-companion/src/webview/components/PromptAttachments.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ describe("PromptAttachments", () => {
4848

4949
describe("usePromptAttachments", () => {
5050
function TestComponent() {
51-
const { attachments, handlePaste, removeAttachment, clearAttachments, getImageUrls } = usePromptAttachments();
51+
const { attachments, handlePaste, removeAttachment, clearAttachments, getImageUrls, loadImages } =
52+
usePromptAttachments();
5253

5354
return (
5455
<div>
@@ -61,6 +62,9 @@ describe("usePromptAttachments", () => {
6162
<button data-testid="clear" onClick={clearAttachments}>
6263
Clear
6364
</button>
65+
<button data-testid="loadImages" onClick={() => loadImages(["data:image/png;base64,xyz"])}>
66+
Load Images
67+
</button>
6468
</div>
6569
);
6670
}
@@ -108,4 +112,48 @@ describe("usePromptAttachments", () => {
108112
fireEvent.click(screen.getByTestId("clear"));
109113
expect(screen.getByTestId("count").textContent).toBe("0");
110114
});
115+
116+
it("loads images from URLs (for editing)", async () => {
117+
render(<TestComponent />);
118+
119+
expect(screen.getByTestId("count").textContent).toBe("0");
120+
121+
fireEvent.click(screen.getByTestId("loadImages"));
122+
123+
expect(screen.getByTestId("count").textContent).toBe("1");
124+
expect(screen.getByTestId("urls").textContent).toBe("data:image/png;base64,xyz");
125+
});
126+
127+
it("does not duplicate existing images when loading", async () => {
128+
render(<TestComponent />);
129+
130+
// Load the same image twice
131+
fireEvent.click(screen.getByTestId("loadImages"));
132+
fireEvent.click(screen.getByTestId("loadImages"));
133+
134+
expect(screen.getByTestId("count").textContent).toBe("1");
135+
});
136+
137+
it("loads multiple images from URLs", async () => {
138+
function MultiLoadTest() {
139+
const { attachments, loadImages } = usePromptAttachments();
140+
return (
141+
<div>
142+
<div data-testid="count">{attachments.length}</div>
143+
<button
144+
data-testid="loadMultiple"
145+
onClick={() => loadImages(["data:image/png;base64,img1", "data:image/jpeg;base64,img2"])}
146+
>
147+
Load Multiple
148+
</button>
149+
</div>
150+
);
151+
}
152+
153+
render(<MultiLoadTest />);
154+
155+
fireEvent.click(screen.getByTestId("loadMultiple"));
156+
157+
expect(screen.getByTestId("count").textContent).toBe("2");
158+
});
111159
});

packages/vscode-ide-companion/src/webview/components/PromptAttachments.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,34 @@ export function usePromptAttachments() {
7878
return attachments.map((a) => a.dataUrl);
7979
}, [attachments]);
8080

81+
// Restore image attachments from a list of data URLs (used when editing a message).
82+
const loadImages = React.useCallback((imageUrls: string[]) => {
83+
if (!imageUrls || imageUrls.length === 0) return;
84+
setAttachments((prev) => {
85+
const existing = new Set(prev.map((a) => a.dataUrl));
86+
const restored = imageUrls
87+
.filter((url) => !existing.has(url))
88+
.map((url) => {
89+
nextIdRef.current += 1;
90+
return {
91+
id: nextIdRef.current,
92+
name: ATTACHMENT_LABEL,
93+
mimeType: url.startsWith("data:") ? url.slice(5, url.indexOf(";")) || "image/png" : "image/png",
94+
dataUrl: url,
95+
label: ATTACHMENT_LABEL,
96+
};
97+
});
98+
return [...prev, ...restored];
99+
});
100+
}, []);
101+
81102
return {
82103
attachments,
83104
handlePaste,
84105
removeAttachment,
85106
clearAttachments,
86107
getImageUrls,
108+
loadImages,
87109
};
88110
}
89111

packages/vscode-ide-companion/src/webview/context/ChatProvider.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
TokenTelemetry,
1111
SkillInfo,
1212
ActiveEditor,
13+
EditingMessage,
1314
} from "@/webview/types";
1415

1516
// ============================================================================
@@ -31,6 +32,7 @@ interface ChatContextValue {
3132
selectSession: (sessionId: string) => Promise<void>;
3233
denyPermission: (sessionId: string) => Promise<void>;
3334
setSelectedSkills: (skills: SkillInfo[]) => void;
35+
editMessage: (editingMessage: EditingMessage | null) => void;
3436
};
3537
}
3638

@@ -56,6 +58,7 @@ const initialState: AppState = {
5658
permissionPromptState: null,
5759
pendingPermissionReply: null,
5860
activeEditor: null,
61+
editingMessage: null,
5962
};
6063

6164
function appReducer(state: AppState, action: AppAction): AppState {
@@ -143,6 +146,8 @@ function appReducer(state: AppState, action: AppAction): AppState {
143146
return { ...state, messages: [], lastMessageRole: null };
144147
case "SET_ACTIVE_EDITOR":
145148
return { ...state, activeEditor: action.editor };
149+
case "SET_EDITING_MESSAGE":
150+
return { ...state, editingMessage: action.editingMessage };
146151
default:
147152
return state;
148153
}
@@ -325,6 +330,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
325330
dispatch({ type: "SET_SELECTED_SKILLS", skills });
326331
}, []);
327332

333+
const editMessage = useCallback((editingMessage: EditingMessage | null) => {
334+
dispatch({ type: "SET_EDITING_MESSAGE", editingMessage });
335+
}, []);
336+
328337
const contextValue: ChatContextValue = {
329338
state,
330339
dispatch,
@@ -335,6 +344,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
335344
selectSession,
336345
denyPermission,
337346
setSelectedSkills,
347+
editMessage,
338348
},
339349
};
340350

0 commit comments

Comments
 (0)