Skip to content

Commit 19abea3

Browse files
committed
feat: implement hybrid prompt history with position reset
- In chat: Use conversation messages (user_feedback), newest first - Out of chat: Use task history, oldest first - Reset navigation position when switching between history sources - Switch from taskHistory to clineMessages for active conversations - Maintain backward compatibility with task history fallback - Add comprehensive tests for hybrid behavior and position reset This provides intuitive UX where: - Users navigate recent conversation messages during tasks (newest first) - Users access initial task prompts when starting fresh (oldest first) - Navigation always starts fresh when switching contexts
1 parent 8a892d3 commit 19abea3

File tree

3 files changed

+184
-63
lines changed

3 files changed

+184
-63
lines changed

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
7777
pinnedApiConfigs,
7878
togglePinnedApiConfig,
7979
taskHistory,
80+
clineMessages,
8081
} = useExtensionState()
8182

8283
// Find the ID and display text for the currently selected API configuration
@@ -163,6 +164,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
163164
resetHistoryNavigation,
164165
resetOnInputChange,
165166
} = usePromptHistory({
167+
clineMessages,
166168
taskHistory,
167169
cwd,
168170
inputValue,
@@ -492,7 +494,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
492494
setInputValue(newValue)
493495

494496
// Reset history navigation when user types
495-
resetOnInputChange(newValue)
497+
resetOnInputChange()
496498

497499
const newCursorPosition = e.target.selectionStart
498500
setCursorPosition(newCursorPosition)

webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx

Lines changed: 122 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -422,10 +422,10 @@ describe("ChatTextArea", () => {
422422
})
423423

424424
describe("prompt history navigation", () => {
425-
const mockTaskHistory = [
426-
{ task: "First prompt", workspace: "/test/workspace" },
427-
{ task: "Second prompt", workspace: "/test/workspace" },
428-
{ task: "Third prompt", workspace: "/test/workspace" },
425+
const mockClineMessages = [
426+
{ type: "say", say: "user_feedback", text: "First prompt", ts: 1000 },
427+
{ type: "say", say: "user_feedback", text: "Second prompt", ts: 2000 },
428+
{ type: "say", say: "user_feedback", text: "Third prompt", ts: 3000 },
429429
]
430430

431431
beforeEach(() => {
@@ -435,7 +435,8 @@ describe("ChatTextArea", () => {
435435
apiConfiguration: {
436436
apiProvider: "anthropic",
437437
},
438-
taskHistory: mockTaskHistory,
438+
taskHistory: [],
439+
clineMessages: mockClineMessages,
439440
cwd: "/test/workspace",
440441
})
441442
})
@@ -451,8 +452,8 @@ describe("ChatTextArea", () => {
451452
// Simulate arrow up key press
452453
fireEvent.keyDown(textarea, { key: "ArrowUp" })
453454

454-
// Should set the most recent prompt (last in array)
455-
expect(setInputValue).toHaveBeenCalledWith("First prompt")
455+
// Should set the newest conversation message (first in reversed array)
456+
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
456457
})
457458

458459
it("should navigate through history with multiple arrow up presses", () => {
@@ -463,14 +464,14 @@ describe("ChatTextArea", () => {
463464

464465
const textarea = container.querySelector("textarea")!
465466

466-
// First arrow up - most recent prompt
467+
// First arrow up - newest conversation message
467468
fireEvent.keyDown(textarea, { key: "ArrowUp" })
468-
expect(setInputValue).toHaveBeenCalledWith("First prompt")
469+
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
469470

470471
// Update input value to simulate the state change
471472
setInputValue.mockClear()
472473

473-
// Second arrow up - previous prompt
474+
// Second arrow up - previous conversation message
474475
fireEvent.keyDown(textarea, { key: "ArrowUp" })
475476
expect(setInputValue).toHaveBeenCalledWith("Second prompt")
476477
})
@@ -483,14 +484,14 @@ describe("ChatTextArea", () => {
483484

484485
const textarea = container.querySelector("textarea")!
485486

486-
// Go back in history first (index 0 -> "First prompt", then index 1 -> "Second prompt")
487+
// Go back in history first (index 0 -> "Third prompt", then index 1 -> "Second prompt")
487488
fireEvent.keyDown(textarea, { key: "ArrowUp" })
488489
fireEvent.keyDown(textarea, { key: "ArrowUp" })
489490
setInputValue.mockClear()
490491

491492
// Navigate forward (from index 1 back to index 0)
492493
fireEvent.keyDown(textarea, { key: "ArrowDown" })
493-
expect(setInputValue).toHaveBeenCalledWith("First prompt")
494+
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
494495
})
495496

496497
it("should preserve current input when starting navigation", () => {
@@ -503,13 +504,12 @@ describe("ChatTextArea", () => {
503504

504505
// Navigate to history
505506
fireEvent.keyDown(textarea, { key: "ArrowUp" })
506-
expect(setInputValue).toHaveBeenCalledWith("First prompt")
507+
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
507508

508509
setInputValue.mockClear()
509510

510511
// Navigate back to current input
511512
fireEvent.keyDown(textarea, { key: "ArrowDown" })
512-
fireEvent.keyDown(textarea, { key: "ArrowDown" })
513513
expect(setInputValue).toHaveBeenCalledWith("Current input")
514514
})
515515

@@ -570,14 +570,14 @@ describe("ChatTextArea", () => {
570570
// With empty input, cursor is at first line by default
571571
// Arrow up should navigate history
572572
fireEvent.keyDown(textarea, { key: "ArrowUp" })
573-
expect(setInputValue).toHaveBeenCalledWith("First prompt")
573+
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
574574
})
575575

576576
it("should filter history by current workspace", () => {
577-
const mixedTaskHistory = [
578-
{ task: "Workspace 1 prompt", workspace: "/test/workspace" },
579-
{ task: "Other workspace prompt", workspace: "/other/workspace" },
580-
{ task: "Workspace 1 prompt 2", workspace: "/test/workspace" },
577+
const mixedClineMessages = [
578+
{ type: "say", say: "user_feedback", text: "Workspace 1 prompt", ts: 1000 },
579+
{ type: "say", say: "user_feedback", text: "Other workspace prompt", ts: 2000 },
580+
{ type: "say", say: "user_feedback", text: "Workspace 1 prompt 2", ts: 3000 },
581581
]
582582

583583
;(useExtensionState as jest.Mock).mockReturnValue({
@@ -586,7 +586,8 @@ describe("ChatTextArea", () => {
586586
apiConfiguration: {
587587
apiProvider: "anthropic",
588588
},
589-
taskHistory: mixedTaskHistory,
589+
taskHistory: [],
590+
clineMessages: mixedClineMessages,
590591
cwd: "/test/workspace",
591592
})
592593

@@ -597,23 +598,24 @@ describe("ChatTextArea", () => {
597598

598599
const textarea = container.querySelector("textarea")!
599600

600-
// Should only show prompts from current workspace
601+
// Should show conversation messages newest first (after reverse)
601602
fireEvent.keyDown(textarea, { key: "ArrowUp" })
602-
expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt")
603+
expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2")
603604

604605
setInputValue.mockClear()
605606
fireEvent.keyDown(textarea, { key: "ArrowUp" })
606-
expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2")
607+
expect(setInputValue).toHaveBeenCalledWith("Other workspace prompt")
607608
})
608609

609-
it("should handle empty task history gracefully", () => {
610+
it("should handle empty conversation history gracefully", () => {
610611
;(useExtensionState as jest.Mock).mockReturnValue({
611612
filePaths: [],
612613
openedTabs: [],
613614
apiConfiguration: {
614615
apiProvider: "anthropic",
615616
},
616617
taskHistory: [],
618+
clineMessages: [],
617619
cwd: "/test/workspace",
618620
})
619621

@@ -629,12 +631,12 @@ describe("ChatTextArea", () => {
629631
expect(setInputValue).not.toHaveBeenCalled()
630632
})
631633

632-
it("should ignore empty or whitespace-only tasks", () => {
633-
const taskHistoryWithEmpty = [
634-
{ task: "Valid prompt", workspace: "/test/workspace" },
635-
{ task: "", workspace: "/test/workspace" },
636-
{ task: " ", workspace: "/test/workspace" },
637-
{ task: "Another valid prompt", workspace: "/test/workspace" },
634+
it("should ignore empty or whitespace-only messages", () => {
635+
const clineMessagesWithEmpty = [
636+
{ type: "say", say: "user_feedback", text: "Valid prompt", ts: 1000 },
637+
{ type: "say", say: "user_feedback", text: "", ts: 2000 },
638+
{ type: "say", say: "user_feedback", text: " ", ts: 3000 },
639+
{ type: "say", say: "user_feedback", text: "Another valid prompt", ts: 4000 },
638640
]
639641

640642
;(useExtensionState as jest.Mock).mockReturnValue({
@@ -643,7 +645,8 @@ describe("ChatTextArea", () => {
643645
apiConfiguration: {
644646
apiProvider: "anthropic",
645647
},
646-
taskHistory: taskHistoryWithEmpty,
648+
taskHistory: [],
649+
clineMessages: clineMessagesWithEmpty,
647650
cwd: "/test/workspace",
648651
})
649652

@@ -654,13 +657,99 @@ describe("ChatTextArea", () => {
654657

655658
const textarea = container.querySelector("textarea")!
656659

657-
// Should skip empty tasks
660+
// Should skip empty messages, newest first for conversation
661+
fireEvent.keyDown(textarea, { key: "ArrowUp" })
662+
expect(setInputValue).toHaveBeenCalledWith("Another valid prompt")
663+
664+
setInputValue.mockClear()
658665
fireEvent.keyDown(textarea, { key: "ArrowUp" })
659666
expect(setInputValue).toHaveBeenCalledWith("Valid prompt")
667+
})
668+
669+
it("should use task history (oldest first) when no conversation messages exist", () => {
670+
const mockTaskHistory = [
671+
{ task: "First task", workspace: "/test/workspace" },
672+
{ task: "Second task", workspace: "/test/workspace" },
673+
{ task: "Third task", workspace: "/test/workspace" },
674+
]
675+
676+
;(useExtensionState as jest.Mock).mockReturnValue({
677+
filePaths: [],
678+
openedTabs: [],
679+
apiConfiguration: {
680+
apiProvider: "anthropic",
681+
},
682+
taskHistory: mockTaskHistory,
683+
clineMessages: [], // No conversation messages
684+
cwd: "/test/workspace",
685+
})
686+
687+
const setInputValue = jest.fn()
688+
const { container } = render(
689+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
690+
)
691+
692+
const textarea = container.querySelector("textarea")!
693+
694+
// Should show task history oldest first (chronological order)
695+
fireEvent.keyDown(textarea, { key: "ArrowUp" })
696+
expect(setInputValue).toHaveBeenCalledWith("First task")
660697

661698
setInputValue.mockClear()
662699
fireEvent.keyDown(textarea, { key: "ArrowUp" })
663-
expect(setInputValue).toHaveBeenCalledWith("Another valid prompt")
700+
expect(setInputValue).toHaveBeenCalledWith("Second task")
701+
})
702+
703+
it("should reset navigation position when switching between history sources", () => {
704+
const setInputValue = jest.fn()
705+
const { rerender } = render(
706+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
707+
)
708+
709+
// Start with task history
710+
;(useExtensionState as jest.Mock).mockReturnValue({
711+
filePaths: [],
712+
openedTabs: [],
713+
apiConfiguration: {
714+
apiProvider: "anthropic",
715+
},
716+
taskHistory: [
717+
{ task: "Task 1", workspace: "/test/workspace" },
718+
{ task: "Task 2", workspace: "/test/workspace" },
719+
],
720+
clineMessages: [],
721+
cwd: "/test/workspace",
722+
})
723+
724+
rerender(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
725+
726+
const textarea = document.querySelector("textarea")!
727+
728+
// Navigate in task history
729+
fireEvent.keyDown(textarea, { key: "ArrowUp" })
730+
expect(setInputValue).toHaveBeenCalledWith("Task 1")
731+
732+
// Switch to conversation messages
733+
;(useExtensionState as jest.Mock).mockReturnValue({
734+
filePaths: [],
735+
openedTabs: [],
736+
apiConfiguration: {
737+
apiProvider: "anthropic",
738+
},
739+
taskHistory: [],
740+
clineMessages: [
741+
{ type: "say", say: "user_feedback", text: "Message 1", ts: 1000 },
742+
{ type: "say", say: "user_feedback", text: "Message 2", ts: 2000 },
743+
],
744+
cwd: "/test/workspace",
745+
})
746+
747+
setInputValue.mockClear()
748+
rerender(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
749+
750+
// Should start from beginning of conversation history (newest first)
751+
fireEvent.keyDown(textarea, { key: "ArrowUp" })
752+
expect(setInputValue).toHaveBeenCalledWith("Message 2")
664753
})
665754
})
666755
})

0 commit comments

Comments
 (0)