mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
feat(app): recall sent messages with Up and Down in an empty composer
Fixing a prompt after interrupting it, or retrying one after a provider error, meant scrolling up and copying it out of the chat. Up in an empty agent composer now fills the last message sent to that agent; Up walks older, Down walks newer and past the newest back to empty. History is read from the loaded timeline on key press, not kept in a separate store, so it follows rewinds, reloads and other devices and costs nothing while a turn streams. Recall only starts from an empty composer, so it never discards a half-written draft. While a recalled message is unedited the autocomplete stays hidden, otherwise a recalled `/command` would open the picker and Enter would pick an option instead of sending.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,11 @@ import {
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -995,6 +1000,7 @@ interface ComposerAutocompleteHandle {
|
||||
function ComposerAutocompleteBinding({
|
||||
text,
|
||||
cursor,
|
||||
recall,
|
||||
configuration,
|
||||
inputRef,
|
||||
anchorRef,
|
||||
@@ -1003,6 +1009,7 @@ function ComposerAutocompleteBinding({
|
||||
}: {
|
||||
text: ComposerTextSource;
|
||||
cursor: StoreApi<number>;
|
||||
recall: ComposerRecall;
|
||||
configuration: Omit<
|
||||
Parameters<typeof useAgentAutocomplete>[0],
|
||||
"userInput" | "cursorIndex" | "onAutocompleteApplied"
|
||||
@@ -1020,9 +1027,11 @@ function ComposerAutocompleteBinding({
|
||||
cursorIndex: Math.min(cursorIndex, userInput.length),
|
||||
onAutocompleteApplied: () => inputRef.current?.focus(),
|
||||
});
|
||||
// A recalled message is sent as is; its leading `/command` must not open the picker.
|
||||
const isShown = show && !recall.isShowing(userInput);
|
||||
const handleKeyPress = autocomplete.onKeyPress;
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
|
||||
show,
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
|
||||
isShown,
|
||||
handleKeyPress,
|
||||
]);
|
||||
const selectOption = autocomplete.onSelectOption;
|
||||
@@ -1032,7 +1041,7 @@ function ComposerAutocompleteBinding({
|
||||
);
|
||||
return (
|
||||
<ComposerAutocomplete
|
||||
visible={autocomplete.isVisible && show}
|
||||
visible={autocomplete.isVisible && isShown}
|
||||
anchorRef={anchorRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
@@ -1362,6 +1371,7 @@ function ComposerContentImpl({
|
||||
);
|
||||
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
|
||||
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
|
||||
const recall = useMemo(() => new ComposerRecall(), []);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
|
||||
const nextPendingFileId = useRef(0);
|
||||
@@ -1981,10 +1991,19 @@ function ComposerContentImpl({
|
||||
|
||||
const hasSendableContent = hasText || selectedAttachments.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
|
||||
[],
|
||||
(event: ComposerKeyPressEvent) => {
|
||||
if (autocompleteRef.current?.onKeyPress(event)) return true;
|
||||
const recalledText = recall.handleKeyPress(event, () =>
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
|
||||
);
|
||||
if (recalledText === null) return false;
|
||||
event.preventDefault();
|
||||
replaceUserInput(recalledText);
|
||||
return true;
|
||||
},
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
);
|
||||
|
||||
const cancelButtonStyle = useMemo(
|
||||
@@ -2423,6 +2442,7 @@ function ComposerContentImpl({
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorRef={messageInputContainerRef}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface ComposerInputSnapshot {
|
||||
|
||||
export interface ComposerKeyPressEvent {
|
||||
key: string;
|
||||
/** Shift, Alt, Ctrl, or Meta was held. */
|
||||
hasModifier: boolean;
|
||||
preventDefault: () => void;
|
||||
input: ComposerInputSnapshot;
|
||||
}
|
||||
@@ -210,6 +212,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
@@ -411,17 +414,18 @@ function handleDesktopKeyPressImpl(
|
||||
): void {
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
|
||||
|
||||
if (ctx.onKeyPressCallback) {
|
||||
const handled = ctx.onKeyPressCallback({
|
||||
key: event.nativeEvent.key,
|
||||
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
|
||||
preventDefault: () => event.preventDefault(),
|
||||
input: ctx.input,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
|
||||
const SENT = ["add tests", "fix the auth bug"];
|
||||
|
||||
function press(
|
||||
key: string,
|
||||
text: string,
|
||||
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
|
||||
): ComposerKeyPressEvent {
|
||||
const caret = options.caret ?? text.length;
|
||||
return {
|
||||
key,
|
||||
hasModifier: options.hasModifier ?? false,
|
||||
preventDefault: () => undefined,
|
||||
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
|
||||
function composerWithRecall(sent: readonly string[] = SENT) {
|
||||
const recall = new ComposerRecall();
|
||||
let text = "";
|
||||
return {
|
||||
recall,
|
||||
get text() {
|
||||
return text;
|
||||
},
|
||||
type(next: string) {
|
||||
text = next;
|
||||
},
|
||||
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
|
||||
const result = recall.handleKeyPress(press(key, text, options), () => sent);
|
||||
if (result !== null) text = result;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComposerRecall", () => {
|
||||
it("walks back through sent messages and forward to an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp")).toBe("add tests");
|
||||
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
|
||||
expect(composer.press("ArrowDown")).toBe("add tests");
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the caret to the input past the oldest message", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.text).toBe("fix the auth bug");
|
||||
});
|
||||
|
||||
it("only starts from an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.type("half written");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores Down from an empty composer and Up with no history", () => {
|
||||
expect(composerWithRecall().press("ArrowDown")).toBeNull();
|
||||
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the arrows back to the caret once the recalled message is edited", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.type("add tests for login");
|
||||
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves between lines of a multi-line message before changing message", () => {
|
||||
const composer = composerWithRecall(["first line\nsecond line", "older"]);
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
|
||||
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
|
||||
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
|
||||
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores modified arrows, other keys, and a selection", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
|
||||
expect(composer.press("Enter")).toBeNull();
|
||||
composer.press("ArrowUp");
|
||||
const recall = composer.recall;
|
||||
expect(
|
||||
recall.handleKeyPress(
|
||||
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
|
||||
() => SENT,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
|
||||
interface RecalledMessage {
|
||||
index: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the messages already sent to an agent from an empty composer, like a
|
||||
* shell's Up/Down history. A message stays recalled only while the composer
|
||||
* still holds its exact text; the first edit hands the arrows back to the caret.
|
||||
*/
|
||||
export class ComposerRecall {
|
||||
private recalled: RecalledMessage | null = null;
|
||||
|
||||
/** True while the composer shows a recalled message the user has not edited. */
|
||||
isShowing(text: string): boolean {
|
||||
return this.recalled !== null && this.recalled.text === text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text the composer should show for this key press, or null to
|
||||
* leave the key to the input. `readSentMessages` is newest first and is only
|
||||
* read for keys recall acts on.
|
||||
*/
|
||||
handleKeyPress(
|
||||
event: ComposerKeyPressEvent,
|
||||
readSentMessages: () => readonly string[],
|
||||
): string | null {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
|
||||
if (event.hasModifier) return null;
|
||||
const { text, selection } = event.input;
|
||||
if (selection.start !== selection.end) return null;
|
||||
|
||||
const recalled = this.isShowing(text) ? this.recalled : null;
|
||||
if (!recalled) {
|
||||
this.recalled = null;
|
||||
if (event.key !== "ArrowUp" || text.length > 0) return null;
|
||||
return this.show(readSentMessages(), 0);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!isCaretOnFirstLine(text, selection.start)) return null;
|
||||
return this.show(readSentMessages(), recalled.index + 1);
|
||||
}
|
||||
if (!isCaretOnLastLine(text, selection.start)) return null;
|
||||
if (recalled.index === 0) {
|
||||
this.recalled = null;
|
||||
return "";
|
||||
}
|
||||
return this.show(readSentMessages(), recalled.index - 1);
|
||||
}
|
||||
|
||||
private show(messages: readonly string[], index: number): string | null {
|
||||
const text = messages[index];
|
||||
if (text === undefined) return null;
|
||||
this.recalled = { index, text };
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function isCaretOnFirstLine(text: string, caret: number): boolean {
|
||||
return !text.slice(0, caret).includes("\n");
|
||||
}
|
||||
|
||||
function isCaretOnLastLine(text: string, caret: number): boolean {
|
||||
return !text.slice(caret).includes("\n");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
@@ -235,6 +236,25 @@ describe("agent timeline state", () => {
|
||||
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
|
||||
});
|
||||
|
||||
it("lists sent messages newest first, including the live head", () => {
|
||||
initializeTestSession();
|
||||
const sent = (id: string, text: string) => ({ ...submittedMessage(id), text });
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")],
|
||||
head: [sent("m4", " "), sent("m5", "refactor the wrong module")],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]);
|
||||
expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves older availability when an applied response leaves it unchanged", () => {
|
||||
initializeTestSession();
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
@@ -332,6 +332,26 @@ export function selectAgentTimelineState(
|
||||
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
|
||||
}
|
||||
|
||||
/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */
|
||||
export function selectAgentSentMessages(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): string[] {
|
||||
if (!session) return [];
|
||||
const items = [
|
||||
...(session.agentStreamTail.get(agentId) ?? []),
|
||||
...(session.agentStreamHead.get(agentId) ?? []),
|
||||
];
|
||||
const messages: string[] = [];
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item.kind !== "user_message" || item.text.trim().length === 0) continue;
|
||||
if (messages[messages.length - 1] === item.text) continue;
|
||||
messages.push(item.text);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function selectAgentTurnPresentation(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
|
||||
Reference in New Issue
Block a user