Skip to content

Commit 848a78a

Browse files
committed
feat(ui): 实现输入框历史消息导航功能并添加轮播组件
- 在 InputPrompt 组件中添加 messages 属性以支持历史消息导航 - 实现历史消息的存储、索引管理及键盘上下箭头导航功能 - 支持从传入的 messages 中加载用户消息作为历史记录 - 在历史消息浏览模式下输入非方向键时退出浏览 - 完善历史消息导航的边界处理及草稿恢复机制 - 添加 InputPrompt 组件相关单元测试覆盖历史消息导航逻辑 - 新增通用 Carousel 轮播组件及其相关子组件和按钮控件 - 在 package.json 中添加 embla-carousel-react 依赖支持轮播实现
1 parent 2df1ddd commit 848a78a

6 files changed

Lines changed: 567 additions & 16 deletions

File tree

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/vscode-ide-companion/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"@webview-rpc/host": "^1.0.17",
102102
"@webview-rpc/react-query": "^1.0.17",
103103
"cmdk": "^1.1.1",
104+
"embla-carousel-react": "^8.6.0",
104105
"framer-motion": "^12.42.2",
105106
"highlight.js": "^11.11.1",
106107
"lucide-react": "^1.21.0",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default function App() {
5151
tokenTelemetry={state.tokenTelemetry}
5252
activeEditor={state.activeEditor}
5353
editingMessage={state.editingMessage}
54+
messages={state.messages}
5455
onSendPrompt={actions.sendPrompt}
5556
onInterrupt={actions.interrupt}
5657
onSelectSkills={actions.setSelectedSkills}

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

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ vi.mock("lucide-react", () => ({
106106
StopCircle: vi.fn(() => <span data-testid="stop-icon" />),
107107
ChevronDown: vi.fn(() => <span data-testid="chevron" />),
108108
Send: vi.fn(() => <span data-testid="send-icon" />),
109+
Reply: vi.fn(() => <span data-testid="reply-icon" />),
109110
FileCodeIcon: vi.fn(() => <span data-testid="file-icon" />),
110111
GraduationCap: vi.fn(() => <span data-testid="graduation-icon" />),
111112
X: vi.fn(() => <span data-testid="x-icon" />),
112113
AlertCircle: vi.fn(() => <span data-testid="alert-icon" />),
113114
CheckCircle: vi.fn(() => <span data-testid="check-icon" />),
114115
Loader2: vi.fn(() => <span data-testid="loader-icon" />),
116+
Loader2Icon: vi.fn(() => <span data-testid="loader-icon" />),
115117
Settings: vi.fn(() => <span data-testid="settings-icon" />),
116118
Info: vi.fn(() => <span data-testid="info-icon" />),
117119
}));
@@ -146,6 +148,7 @@ const defaultProps: InputPromptProps = {
146148
},
147149
activeEditor: null,
148150
editingMessage: null,
151+
messages: [],
149152
onSendPrompt: mockOnSendPrompt,
150153
onInterrupt: mockOnInterrupt,
151154
onSelectSkills: mockOnSelectSkills,
@@ -208,6 +211,274 @@ describe("InputPrompt", () => {
208211
});
209212
});
210213

214+
describe("History navigation", () => {
215+
it("adds sent messages to history", async () => {
216+
render(<InputPrompt {...defaultProps} />);
217+
const textarea = screen.getByRole("textbox");
218+
219+
fireEvent.change(textarea, { target: { value: "First message" } });
220+
fireEvent.keyDown(textarea, { key: "Enter" });
221+
expect(mockOnSendPrompt).toHaveBeenCalledWith("First message", [], [], undefined);
222+
223+
fireEvent.change(textarea, { target: { value: "Second message" } });
224+
fireEvent.keyDown(textarea, { key: "Enter" });
225+
expect(mockOnSendPrompt).toHaveBeenCalledWith("Second message", [], [], undefined);
226+
});
227+
228+
it("navigates to previous history with ArrowUp when at start", async () => {
229+
render(<InputPrompt {...defaultProps} />);
230+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
231+
232+
// Add two messages to history
233+
fireEvent.change(textarea, { target: { value: "First message" } });
234+
fireEvent.keyDown(textarea, { key: "Enter" });
235+
fireEvent.change(textarea, { target: { value: "Second message" } });
236+
fireEvent.keyDown(textarea, { key: "Enter" });
237+
238+
// Clear and navigate with ArrowUp
239+
fireEvent.change(textarea, { target: { value: "" } });
240+
// Simulate cursor at start (selectionStart === 0)
241+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
242+
243+
// Should show "Second message" (most recent)
244+
await waitFor(() => {
245+
expect((textarea as HTMLTextAreaElement).value).toBe("Second message");
246+
});
247+
});
248+
249+
it("navigates through history with multiple ArrowUp presses", async () => {
250+
render(<InputPrompt {...defaultProps} />);
251+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
252+
253+
// Add three messages to history
254+
fireEvent.change(textarea, { target: { value: "First" } });
255+
fireEvent.keyDown(textarea, { key: "Enter" });
256+
fireEvent.change(textarea, { target: { value: "Second" } });
257+
fireEvent.keyDown(textarea, { key: "Enter" });
258+
fireEvent.change(textarea, { target: { value: "Third" } });
259+
fireEvent.keyDown(textarea, { key: "Enter" });
260+
261+
// Navigate with ArrowUp twice
262+
fireEvent.change(textarea, { target: { value: "" } });
263+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
264+
await waitFor(() => {
265+
expect((textarea as HTMLTextAreaElement).value).toBe("Third");
266+
});
267+
268+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
269+
await waitFor(() => {
270+
expect((textarea as HTMLTextAreaElement).value).toBe("Second");
271+
});
272+
});
273+
274+
it("navigates to next history with ArrowDown", async () => {
275+
render(<InputPrompt {...defaultProps} />);
276+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
277+
278+
// Add messages to history
279+
fireEvent.change(textarea, { target: { value: "First" } });
280+
fireEvent.keyDown(textarea, { key: "Enter" });
281+
fireEvent.change(textarea, { target: { value: "Second" } });
282+
fireEvent.keyDown(textarea, { key: "Enter" });
283+
fireEvent.change(textarea, { target: { value: "Third" } });
284+
fireEvent.keyDown(textarea, { key: "Enter" });
285+
286+
// Navigate to history - shows most recent
287+
fireEvent.change(textarea, { target: { value: "" } });
288+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
289+
await waitFor(() => {
290+
expect((textarea as HTMLTextAreaElement).value).toBe("Third");
291+
});
292+
293+
// ArrowUp again - goes to older
294+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
295+
await waitFor(() => {
296+
expect((textarea as HTMLTextAreaElement).value).toBe("Second");
297+
});
298+
299+
// ArrowDown - goes back to newer
300+
fireEvent.keyDown(textarea, { key: "ArrowDown" });
301+
await waitFor(() => {
302+
expect((textarea as HTMLTextAreaElement).value).toBe("Third");
303+
});
304+
305+
// ArrowDown again - exits and restores draft (empty)
306+
fireEvent.keyDown(textarea, { key: "ArrowDown" });
307+
await waitFor(() => {
308+
expect((textarea as HTMLTextAreaElement).value).toBe("");
309+
});
310+
});
311+
312+
it("restores draft when navigating past the end of history", async () => {
313+
render(<InputPrompt {...defaultProps} />);
314+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
315+
316+
// Add messages to history
317+
fireEvent.change(textarea, { target: { value: "First" } });
318+
fireEvent.keyDown(textarea, { key: "Enter" });
319+
fireEvent.change(textarea, { target: { value: "Second" } });
320+
fireEvent.keyDown(textarea, { key: "Enter" });
321+
322+
// Navigate to history (entering with empty value preserves no draft)
323+
fireEvent.change(textarea, { target: { value: "" } });
324+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
325+
await waitFor(() => {
326+
expect((textarea as HTMLTextAreaElement).value).toBe("Second");
327+
});
328+
329+
// Navigate back with ArrowDown (goes forward to end of history)
330+
fireEvent.keyDown(textarea, { key: "ArrowDown" });
331+
await waitFor(() => {
332+
// Reached end of history, restores empty draft
333+
expect((textarea as HTMLTextAreaElement).value).toBe("");
334+
});
335+
});
336+
337+
it("exits history browsing when typing", async () => {
338+
render(<InputPrompt {...defaultProps} />);
339+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
340+
341+
// Add message to history
342+
fireEvent.change(textarea, { target: { value: "History message" } });
343+
fireEvent.keyDown(textarea, { key: "Enter" });
344+
345+
// Navigate to history
346+
fireEvent.change(textarea, { target: { value: "" } });
347+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
348+
await waitFor(() => {
349+
expect((textarea as HTMLTextAreaElement).value).toBe("History message");
350+
});
351+
352+
// Type something - should exit history browsing
353+
fireEvent.change(textarea, { target: { value: "New input" } });
354+
355+
// Next ArrowUp should start from beginning, not from history position
356+
fireEvent.change(textarea, { target: { value: "" } });
357+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
358+
await waitFor(() => {
359+
expect((textarea as HTMLTextAreaElement).value).toBe("History message");
360+
});
361+
});
362+
363+
it("exits history browsing when pressing non-arrow keys", async () => {
364+
render(<InputPrompt {...defaultProps} />);
365+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
366+
367+
// Add message to history
368+
fireEvent.change(textarea, { target: { value: "History message" } });
369+
fireEvent.keyDown(textarea, { key: "Enter" });
370+
371+
// Navigate to history
372+
fireEvent.change(textarea, { target: { value: "" } });
373+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
374+
await waitFor(() => {
375+
expect((textarea as HTMLTextAreaElement).value).toBe("History message");
376+
});
377+
378+
// Press Escape - should exit history browsing but not change value
379+
fireEvent.keyDown(textarea, { key: "Escape" });
380+
expect((textarea as HTMLTextAreaElement).value).toBe("History message");
381+
});
382+
383+
it("does not navigate history when no history exists", () => {
384+
render(<InputPrompt {...defaultProps} />);
385+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
386+
387+
fireEvent.change(textarea, { target: { value: "" } });
388+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
389+
390+
// Value should remain empty
391+
expect((textarea as HTMLTextAreaElement).value).toBe("");
392+
});
393+
394+
it("does not navigate history when caret is not at start/end", async () => {
395+
render(<InputPrompt {...defaultProps} />);
396+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
397+
398+
// Add message to history
399+
fireEvent.change(textarea, { target: { value: "History message" } });
400+
fireEvent.keyDown(textarea, { key: "Enter" });
401+
402+
// Type content with caret in the middle
403+
fireEvent.change(textarea, { target: { value: "Hello World" } });
404+
// Set cursor to middle (after "Hello")
405+
textarea.setSelectionRange(5, 5);
406+
407+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
408+
409+
// Value should remain unchanged since caret is not at start
410+
expect((textarea as HTMLTextAreaElement).value).toBe("Hello World");
411+
});
412+
});
413+
414+
describe("Loading history from messages", () => {
415+
it("loads user messages from messages prop into history", async () => {
416+
const messages = [
417+
{ role: "user", content: "First message from history" },
418+
{ role: "assistant", content: "Assistant response" },
419+
{ role: "user", content: "Second message from history" },
420+
];
421+
render(<InputPrompt {...defaultProps} messages={messages} />);
422+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
423+
424+
// Navigate to history
425+
fireEvent.change(textarea, { target: { value: "" } });
426+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
427+
428+
await waitFor(() => {
429+
expect((textarea as HTMLTextAreaElement).value).toBe("Second message from history");
430+
});
431+
432+
// Navigate to older message
433+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
434+
await waitFor(() => {
435+
expect((textarea as HTMLTextAreaElement).value).toBe("First message from history");
436+
});
437+
});
438+
439+
it("clears history when messages is empty", async () => {
440+
const { rerender } = render(<InputPrompt {...defaultProps} messages={[]} />);
441+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
442+
443+
// Add a message first
444+
fireEvent.change(textarea, { target: { value: "New message" } });
445+
fireEvent.keyDown(textarea, { key: "Enter" });
446+
447+
// Rerender with empty messages
448+
rerender(<InputPrompt {...defaultProps} messages={[]} />);
449+
450+
// Try to navigate history - should not work since history is empty
451+
fireEvent.change(textarea, { target: { value: "" } });
452+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
453+
454+
// Value should remain empty
455+
expect((textarea as HTMLTextAreaElement).value).toBe("");
456+
});
457+
458+
it("ignores non-user messages when building history", async () => {
459+
const messages = [
460+
{ role: "user", content: "User message" },
461+
{ role: "assistant", content: "Should be ignored" },
462+
{ role: "tool", content: "Should also be ignored" },
463+
{ role: "system", content: "Ignored" },
464+
];
465+
render(<InputPrompt {...defaultProps} messages={messages} />);
466+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
467+
468+
// Navigate to history
469+
fireEvent.change(textarea, { target: { value: "" } });
470+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
471+
472+
await waitFor(() => {
473+
expect((textarea as HTMLTextAreaElement).value).toBe("User message");
474+
});
475+
476+
// Second ArrowUp should stay at the same message (already at oldest)
477+
fireEvent.keyDown(textarea, { key: "ArrowUp" });
478+
expect((textarea as HTMLTextAreaElement).value).toBe("User message");
479+
});
480+
});
481+
211482
describe("Loading state", () => {
212483
it("renders interrupt button when loading", () => {
213484
render(<InputPrompt {...defaultProps} loading={true} />);

0 commit comments

Comments
 (0)