Skip to content

Commit 3e519ff

Browse files
authored
feat: Add the ability to navigate up and down between previous prompts using arrow keys while focused on the message editor (#378)
1 parent 7668e34 commit 3e519ff

File tree

4 files changed

+137
-2
lines changed

4 files changed

+137
-2
lines changed

apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
5858
insertChip,
5959
} = useTiptapEditor({
6060
sessionId,
61+
taskId,
6162
placeholder,
6263
disabled,
6364
isCloud,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { getUserPromptsForTask } from "@features/sessions/stores/sessionStore";
2+
import { create } from "zustand";
3+
4+
interface PromptHistoryStore {
5+
index: number;
6+
savedInput: string;
7+
navigateUp: (taskId: string, currentInput: string) => string | null;
8+
navigateDown: (taskId: string) => string | null;
9+
reset: () => void;
10+
}
11+
12+
export const usePromptHistoryStore = create<PromptHistoryStore>((set, get) => ({
13+
index: -1,
14+
savedInput: "",
15+
16+
navigateUp: (taskId, currentInput) => {
17+
const history = getUserPromptsForTask(taskId);
18+
if (history.length === 0) return null;
19+
20+
const { index } = get();
21+
22+
if (index === -1) {
23+
set({ savedInput: currentInput, index: 0 });
24+
return history[history.length - 1] ?? null;
25+
}
26+
27+
if (index >= history.length - 1) return null;
28+
29+
const newIndex = index + 1;
30+
set({ index: newIndex });
31+
return history[history.length - 1 - newIndex] ?? null;
32+
},
33+
34+
navigateDown: (taskId) => {
35+
const { index, savedInput } = get();
36+
if (index === -1) return null;
37+
38+
const history = getUserPromptsForTask(taskId);
39+
40+
if (index > 0) {
41+
const newIndex = index - 1;
42+
set({ index: newIndex });
43+
return history[history.length - 1 - newIndex] ?? null;
44+
}
45+
46+
set({ index: -1, savedInput: "" });
47+
return savedInput;
48+
},
49+
50+
reset: () => set({ index: -1, savedInput: "" }),
51+
}));

apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { toast } from "@renderer/utils/toast";
22
import { useEditor } from "@tiptap/react";
33
import { useCallback, useRef, useState } from "react";
4+
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
45
import type { MentionChip } from "../utils/content";
56
import { contentToXml } from "../utils/content";
67
import { getEditorExtensions } from "./extensions";
78
import { type DraftContext, useDraftSync } from "./useDraftSync";
89

910
export interface UseTiptapEditorOptions {
1011
sessionId: string;
12+
taskId?: string;
1113
placeholder?: string;
1214
disabled?: boolean;
1315
isCloud?: boolean;
@@ -30,6 +32,7 @@ const EDITOR_CLASS =
3032
export function useTiptapEditor(options: UseTiptapEditorOptions) {
3133
const {
3234
sessionId,
35+
taskId,
3336
placeholder = "",
3437
disabled = false,
3538
isCloud = false,
@@ -66,7 +69,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
6669
const submitRef = useRef<() => void>(() => {});
6770
const draftRef = useRef<ReturnType<typeof useDraftSync> | null>(null);
6871

69-
// Track isEmpty state to trigger re-renders when content changes
72+
const historyActions = usePromptHistoryStore.getState();
7073
const [isEmptyState, setIsEmptyState] = useState(true);
7174

7275
const handleCommandSubmit = useCallback((text: string) => {
@@ -91,14 +94,53 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
9194
autofocus: autoFocus ? "end" : false,
9295
editorProps: {
9396
attributes: { class: EDITOR_CLASS },
94-
handleKeyDown: (_view, event) => {
97+
handleKeyDown: (view, event) => {
9598
if (event.key === "Enter" && !event.shiftKey) {
9699
const suggestionPopup = document.querySelector("[data-tippy-root]");
97100
if (suggestionPopup) return false;
98101
event.preventDefault();
102+
historyActions.reset();
99103
submitRef.current();
100104
return true;
101105
}
106+
107+
if (
108+
taskId &&
109+
(event.key === "ArrowUp" || event.key === "ArrowDown")
110+
) {
111+
const currentText = view.state.doc.textContent;
112+
const isEmpty = !currentText.trim();
113+
const { from } = view.state.selection;
114+
const isAtStart = from === 1;
115+
const isAtEnd = from === view.state.doc.content.size - 1;
116+
117+
if (event.key === "ArrowUp" && (isEmpty || isAtStart)) {
118+
const newText = historyActions.navigateUp(taskId, currentText);
119+
if (newText !== null) {
120+
event.preventDefault();
121+
view.dispatch(
122+
view.state.tr
123+
.delete(1, view.state.doc.content.size - 1)
124+
.insertText(newText, 1),
125+
);
126+
return true;
127+
}
128+
}
129+
130+
if (event.key === "ArrowDown" && (isEmpty || isAtEnd)) {
131+
const newText = historyActions.navigateDown(taskId);
132+
if (newText !== null) {
133+
event.preventDefault();
134+
view.dispatch(
135+
view.state.tr
136+
.delete(1, view.state.doc.content.size - 1)
137+
.insertText(newText, 1),
138+
);
139+
return true;
140+
}
141+
}
142+
}
143+
102144
return false;
103145
},
104146
},

apps/array/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,47 @@ export function getAvailableCommandsForTask(
750750
return extractAvailableCommandsFromEvents(session.events);
751751
}
752752

753+
/**
754+
* Extract user prompts from session events.
755+
* Returns an array of user prompt strings, most recent last.
756+
*/
757+
function extractUserPromptsFromEvents(events: AcpMessage[]): string[] {
758+
const prompts: string[] = [];
759+
760+
for (const event of events) {
761+
const msg = event.message;
762+
if (isJsonRpcRequest(msg) && msg.method === "session/prompt") {
763+
const params = msg.params as { prompt?: ContentBlock[] };
764+
if (params?.prompt?.length) {
765+
// Find first visible text block (skip hidden context blocks)
766+
const textBlock = params.prompt.find((b) => {
767+
if (b.type !== "text") return false;
768+
const meta = (b as { _meta?: { ui?: { hidden?: boolean } } })._meta;
769+
return !meta?.ui?.hidden;
770+
});
771+
if (textBlock && textBlock.type === "text") {
772+
prompts.push(textBlock.text);
773+
}
774+
}
775+
}
776+
}
777+
778+
return prompts;
779+
}
780+
781+
/**
782+
* Get user prompts for a task, most recent last.
783+
*/
784+
export function getUserPromptsForTask(taskId: string | undefined): string[] {
785+
if (!taskId) return [];
786+
const sessions = useStore.getState().sessions;
787+
const session = Object.values(sessions).find(
788+
(sess) => sess.taskId === taskId,
789+
);
790+
if (!session?.events) return [];
791+
return extractUserPromptsFromEvents(session.events);
792+
}
793+
753794
// Token refresh subscription
754795
let lastKnownToken: string | null = null;
755796
useAuthStore.subscribe(

0 commit comments

Comments
 (0)