Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/components/canvas/canvas-conversation-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ import type {
QuestionAnswer,
} from "@/lib/types"
import { cn, randomUUID } from "@/lib/utils"
import { userPromptHistory } from "@/lib/composer-history"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import {
getTimelineTurns,
useConversationRuntimeActions,
useConversationRuntimeStore,
} from "@/stores/conversation-runtime-store"
Expand Down Expand Up @@ -711,6 +713,16 @@ export function CanvasConversationSurface({
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
}, [conn.modes, connectionModes, modeId])

// Arrow-key history source, read lazily on the first Up/Down (see
// `MessageInput.getSentHistory`) so streaming tokens cost nothing here.
const getSentHistory = useCallback(
() =>
userPromptHistory(
getTimelineTurns(effectiveConversationId).map((entry) => entry.turn)
),
[effectiveConversationId]
)

const isDraft = dbConversationId == null

return (
Expand Down Expand Up @@ -753,6 +765,7 @@ export function CanvasConversationSurface({
agentType={agentType}
availableCommands={conn.availableCommands ?? []}
draftStorageKey={`canvas-draft:${contextKey}`}
getSentHistory={getSentHistory}
// The card's own connection key doubles as its composer scope: the
// context-usage ring and connection dot in the picker row read it as
// a contextKey (they showed nothing at all while it was undefined).
Expand Down
4 changes: 4 additions & 0 deletions src/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ interface ChatInputProps {
/** Pass-through: see `MessageInput`. */
folderPickerOverride?: ConversationFolderPickerOverride
draftStorageKey?: string | null
/** Pass-through: see `MessageInput.getSentHistory`. */
getSentHistory?: () => string[]
isActive?: boolean
/** Show the composer's flowing active-session border. Set only for the active
* tab when tiled across multiple sessions; passed through to MessageInput. */
Expand Down Expand Up @@ -119,6 +121,7 @@ export const ChatInput = memo(function ChatInput({
attachmentTabId,
folderPickerOverride,
draftStorageKey,
getSentHistory,
isActive,
showActiveFlow,
queue,
Expand Down Expand Up @@ -222,6 +225,7 @@ export const ChatInput = memo(function ChatInput({
attachmentTabId={attachmentTabId}
folderPickerOverride={folderPickerOverride}
draftStorageKey={draftStorageKey}
getSentHistory={getSentHistory}
isActive={isActive}
showActiveFlow={showActiveFlow}
onEnqueue={onEnqueue}
Expand Down
160 changes: 160 additions & 0 deletions src/components/chat/composer/rich-composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,163 @@ describe("RichComposer text paste (plain-text schema)", () => {
expect(ref.current?.getText()).toBe("")
})
})

describe("RichComposer prompt-history Arrow routing", () => {
it("routes ArrowUp to onHistoryKeyDown at the document start and consumes it", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowUp" })

expect(onHistoryKeyDown).toHaveBeenCalledWith("older", expect.anything())
expect(event.defaultPrevented).toBe(true)
})

it("routes ArrowDown to onHistoryKeyDown at the document end", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("end"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowDown" })

expect(onHistoryKeyDown).toHaveBeenCalledWith("newer", expect.anything())
expect(event.defaultPrevented).toBe(true)
})

it("leaves the Arrow keys to the caret away from the edge", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
// Caret at the END: ArrowUp is not "older" here, so it stays a caret move
// and the host is never asked.
act(() => ref.current?.getEditor()?.commands.focus("end"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowUp" })

expect(onHistoryKeyDown).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})

it("keeps the Arrow keys to the caret between paragraphs of one document", async () => {
// A native paste can leave the box holding SEVERAL paragraphs. The start of
// the second one is the start of its block but not of the document, so it
// is a plain "move up a line" — recalling there would swap the whole draft
// out from under the caret.
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() =>
ref.current?.setDoc({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "one" }] },
{ type: "paragraph", content: [{ type: "text", text: "two" }] },
],
})
)
const editor = ref.current?.getEditor()
const dom = editor?.view.dom as HTMLElement

// Start of paragraph two.
act(() => editor?.commands.setTextSelection(6))
expect(pressKey(dom, { key: "ArrowUp" }).defaultPrevented).toBe(false)
// End of paragraph one.
act(() => editor?.commands.setTextSelection(4))
expect(pressKey(dom, { key: "ArrowDown" }).defaultPrevented).toBe(false)

expect(onHistoryKeyDown).not.toHaveBeenCalled()

// The document's own edges still route: start of the first paragraph,
// end of the last.
act(() => editor?.commands.setTextSelection(1))
pressKey(dom, { key: "ArrowUp" })
expect(onHistoryKeyDown).toHaveBeenLastCalledWith(
"older",
expect.anything()
)
act(() => editor?.commands.setTextSelection(9))
pressKey(dom, { key: "ArrowDown" })
expect(onHistoryKeyDown).toHaveBeenLastCalledWith(
"newer",
expect.anything()
)
})

it("keeps Arrow keys for the IME while a composition is in flight", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("ni"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowUp", isComposing: true })

expect(onHistoryKeyDown).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})

it("defers to an open menu before history", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const onExternalMenuKeyDown = vi.fn(() => true)
const { ref } = await mount({
onHistoryKeyDown,
onExternalMenuKeyDown,
isExternalMenuOpen: true,
})
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

pressKey(dom, { key: "ArrowUp" })

expect(onExternalMenuKeyDown).toHaveBeenCalled()
expect(onHistoryKeyDown).not.toHaveBeenCalled()
})

it("does not consume the key when the host declines", async () => {
const onHistoryKeyDown = vi.fn(() => false)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowUp" })

expect(onHistoryKeyDown).toHaveBeenCalledWith("older", expect.anything())
expect(event.defaultPrevented).toBe(false)
})

it("keeps Shift+Arrow for selection instead of history", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

const event = pressKey(dom, { key: "ArrowUp", shiftKey: true })

expect(onHistoryKeyDown).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})

it("keeps Ctrl/Alt+Arrow for word and line jumps", async () => {
const onHistoryKeyDown = vi.fn(() => true)
const { ref } = await mount({ onHistoryKeyDown })
act(() => ref.current?.setText("hello"))
act(() => ref.current?.getEditor()?.commands.focus("start"))
const dom = ref.current?.getEditor()?.view.dom as HTMLElement

expect(
pressKey(dom, { key: "ArrowUp", ctrlKey: true }).defaultPrevented
).toBe(false)
expect(
pressKey(dom, { key: "ArrowUp", altKey: true }).defaultPrevented
).toBe(false)
expect(onHistoryKeyDown).not.toHaveBeenCalled()
})
})
57 changes: 57 additions & 0 deletions src/components/chat/composer/rich-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
type RefObject,
} from "react"
import { type Editor, type JSONContent } from "@tiptap/core"
import { Selection } from "@tiptap/pm/state"
import { EditorContent, useEditor } from "@tiptap/react"
import { exitSuggestion } from "@tiptap/suggestion"

import type { HistoryDirection } from "@/lib/composer-history"
import { isImeCompositionKey } from "@/lib/ime-composition"
import {
NO_KNOWN_INVOCATIONS,
Expand Down Expand Up @@ -192,6 +194,21 @@ export interface RichComposerProps {
* false (e.g. a letter that filters the list) to let normal editing proceed.
*/
onExternalMenuKeyDown?: (event: KeyboardEvent) => boolean
/**
* Arrow-key prompt history (the chat composer's Up/Down recall). Called for a
* bare ArrowUp/ArrowDown BEFORE the caret moves, but ONLY when the collapsed
* selection already sits at the document's first (`"older"`) or last
* (`"newer"`) position — so the caret keeps moving line by line inside a
* multi-line entry, and only a press at an edge switches prompts. Return true
* to consume the key.
*
* Optional on purpose: the task and automation composers pass no handler and
* keep the editor's default Arrow behaviour.
*/
onHistoryKeyDown?: (
direction: HistoryDirection,
event: KeyboardEvent
) => boolean
/**
* Called on paste before the editor handles it. Return true when the paste was
* consumed out-of-band (e.g. an image/file became an attachment) so the editor
Expand Down Expand Up @@ -247,6 +264,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
newlineShortcut,
isExternalMenuOpen,
onExternalMenuKeyDown,
onHistoryKeyDown,
onPasteFiles,
onDropFiles,
onPlainPaste,
Expand All @@ -272,6 +290,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
const newlineShortcutRef = useRef(newlineShortcut)
const isExternalMenuOpenRef = useRef(isExternalMenuOpen)
const onExternalMenuKeyDownRef = useRef(onExternalMenuKeyDown)
const onHistoryKeyDownRef = useRef(onHistoryKeyDown)
const onPasteFilesRef = useRef(onPasteFiles)
const onDropFilesRef = useRef(onDropFiles)
const onPlainPasteRef = useRef(onPlainPaste)
Expand All @@ -292,6 +311,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
newlineShortcutRef.current = newlineShortcut
isExternalMenuOpenRef.current = isExternalMenuOpen
onExternalMenuKeyDownRef.current = onExternalMenuKeyDown
onHistoryKeyDownRef.current = onHistoryKeyDown
onPasteFilesRef.current = onPasteFiles
onDropFilesRef.current = onDropFiles
onPlainPasteRef.current = onPlainPaste
Expand Down Expand Up @@ -385,6 +405,43 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
if (isExternalMenuOpenRef.current) {
return onExternalMenuKeyDownRef.current?.(event) ?? false
}
// Prompt history (chat composer only): a bare Up/Down at the
// document's first/last position steps through sent prompts; anywhere
// else the editor keeps the caret movement, which is what makes a
// multi-line recalled message navigable line by line. Placed after
// the menus so an open panel always wins, and after the IME guard
// above so a CJK candidate list keeps its own Arrow keys.
//
// A modifier keeps the native meaning: Shift+Arrow extends the
// selection (empty until it spans), and Ctrl/Alt+Arrow is a
// word/line jump — none of them is "recall a prompt".
if (
onHistoryKeyDownRef.current &&
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
!event.shiftKey &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
const { selection, doc } = view.state
const older = event.key === "ArrowUp"
// The edge that counts is the DOCUMENT's, not the current block's.
// The box is normally one paragraph of hard breaks, but a native
// paste can leave several paragraphs in it (see the quote
// decoration's scan) — and in one of those, the start of paragraph
// two is an ordinary "move up a line", not a recall.
const atBoundary =
selection.empty &&
(older
? selection.from === Selection.atStart(doc).from
: selection.to === Selection.atEnd(doc).to)
if (atBoundary) {
return onHistoryKeyDownRef.current(
older ? "older" : "newer",
event
)
}
}
// Paste without formatting: Ctrl/⌘+Shift+V routes to the host, which
// owns the clipboard read. Consume the key (suppressing the browser's
// native rich paste) only when the host takes over; otherwise return
Expand Down
4 changes: 4 additions & 0 deletions src/components/chat/conversation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ interface ConversationShellProps {
/** Pass-through: see `MessageInput`. */
folderPickerOverride?: ConversationFolderPickerOverride
draftStorageKey?: string | null
/** Pass-through: see `MessageInput.getSentHistory`. */
getSentHistory?: () => string[]
hideInput?: boolean
/** Optional banner rendered in the composer dock, where the input sits.
* Used with `hideInput` to explain WHY the composer is unavailable (e.g.
Expand Down Expand Up @@ -188,6 +190,7 @@ export function ConversationShell({
attachmentTabId,
folderPickerOverride,
draftStorageKey,
getSentHistory,
hideInput = false,
composerBanner,
feedbackList,
Expand Down Expand Up @@ -366,6 +369,7 @@ export function ConversationShell({
attachmentTabId={attachmentTabId}
folderPickerOverride={folderPickerOverride}
draftStorageKey={draftStorageKey}
getSentHistory={getSentHistory}
isActive={isActive}
showActiveFlow={showActiveFlow}
queue={queue}
Expand Down
Loading
Loading