Skip to content

feat(chat): navigate prompt history with PageUp/PageDown#8

Open
dguerizec wants to merge 4 commits intotwidi:mainfrom
dguerizec:feature/prompt-history
Open

feat(chat): navigate prompt history with PageUp/PageDown#8
dguerizec wants to merge 4 commits intotwidi:mainfrom
dguerizec:feature/prompt-history

Conversation

@dguerizec
Copy link
Contributor

Summary

  • Browse previous user messages in the current session using PageUp (older) and PageDown (newer)
  • Current draft text is preserved and restored when exiting history navigation
  • Typing any character resets the history position
  • Handles both string and array content formats in user messages

Test plan

  • Open a session with several user messages
  • Focus the message input, press PageUp → should show the most recent message
  • Press PageUp again → older messages
  • Press PageDown → navigate back toward newer messages
  • Press PageDown past the newest → restores the original draft text
  • Type something while in history mode → resets to normal input

🤖 Generated with Claude Code

Browse previous user messages in the current session using PageUp
(older) and PageDown (newer). Current draft is preserved and restored
when exiting history navigation. Typing resets history position.
@twidi
Copy link
Owner

twidi commented Mar 10, 2026

Code review

Thanks for the feature — prompt history navigation is a great addition. After a thorough analysis, here are the issues and suggestions I found.


Bugs

1. Missing adjustTextareaHeight() after setting messageText.value in navigateHistory()

Every other code path in this file that programmatically sets messageText.value also calls adjustTextareaHeight()onInput, onFilePickerSelect, onSlashCommandSelect, handleSend, handleReset, and the session change watcher. The navigateHistory function omits it, so the textarea will keep its previous height when recalling a shorter or longer message.

// Back to draft
messageText.value = savedDraft
} else {
messageText.value = history[newIndex]
}
}
const attachButtonId = useId()
const settingsButtonId = useId()

2. Missing Web Component force-update for wa-textarea

The file has extensive comments (around lines 835-848) explaining that setting messageText.value alone is not enough — Vue's :value.prop binding deduplication and Lit's setter dedup can silently skip DOM updates. That's why handleSend, onFilePickerSelect, onSlashCommandSelect, and handleReset all explicitly set textareaRef.value.value and/or the inner textarea.value. navigateHistory() doesn't do this. The bug would surface when navigating to the same history entry twice, or toggling between draft and a history entry that happen to have the same value.

const newIndex = historyIndex.value + delta
if (newIndex < -1) return // Can't go newer than draft
if (newIndex >= history.length) return // Can't go older than oldest
if (historyIndex.value === -1) {
// Entering history mode: save current text
savedDraft = messageText.value
}
historyIndex.value = newIndex
if (newIndex === -1) {
// Back to draft
messageText.value = savedDraft
} else {
messageText.value = history[newIndex]
}
}
const attachButtonId = useId()
const settingsButtonId = useId()

3. historyIndex not reset in handleSend(), handleReset(), and session change watcher

historyIndex is only reset to -1 inside onInput (on user typing). But messageText.value is also cleared or changed in:

  • handleSend() — clears the textarea after sending. If the user sends while browsing history, historyIndex stays at its old value. Next PageUp will skip saving the draft (because historyIndex !== -1), and savedDraft retains stale text.
  • handleReset() — same problem.
  • Session change watcher — when switching sessions, messageText is set from the new session's draft, but historyIndex and savedDraft still reference the old session's state.

const historyIndex = ref(-1)
// Text saved before entering history navigation, restored when exiting
let savedDraft = ''


UX concerns

4. Activates regardless of textarea content — no safeguard

PageUp/PageDown triggers even when the user has typed text. The current content is replaced immediately without any visual cue. While the draft is technically saved in savedDraft, a user who doesn't know about this feature and presses PageUp (e.g., expecting to scroll the page) will see their text vanish. This could be stressful, especially for long messages.

5. No visual feedback for history navigation

There's no indication that the user is in "history mode" — no way to know which message they're viewing, how many are available, or that they've reached the end. The text just silently changes in the textarea.

6. Consider a popup picker instead of inline replacement

A popup similar to the existing slash command picker (/) could be a better UX pattern:

  • Triggered by PageUp (or a dedicated shortcut) to open a list of previous messages
  • Fuzzy filtering by typing
  • Clear visual feedback (the user sees all options and picks explicitly)
  • The current textarea content stays untouched until the user confirms a selection
  • Reuses a pattern already established in the component
  • Solves the performance concern below (parsing happens once when the popup opens, not on every keystroke)

Performance

7. getHistoryTexts() recomputes on every keypress

The function iterates all session items, filters for user_message, and parses the content of each one — every time the user presses PageUp or PageDown. For sessions with hundreds of messages, this is wasteful. If keeping the current inline approach, a cached list (invalidated when new items arrive) would be more appropriate. The popup approach suggested above would also resolve this naturally.

function getHistoryTexts() {
const items = store.getSessionItems(props.sessionId)
const texts = []
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].kind !== 'user_message') continue
const parsed = getParsedContent(items[i])
const content = parsed?.message?.content
if (!content) continue
// content can be a plain string or an array of blocks
if (typeof content === 'string') {
if (content) texts.push(content)
} else if (Array.isArray(content)) {
const textBlock = content.find(b => b.type === 'text')
if (textBlock?.text) texts.push(textBlock.text)
}
}
return texts
}
/**
* Navigate prompt history. delta: -1 = older (PageUp), +1 = newer (PageDown).


Open question

8. Attachments are silently dropped

When recalling a message that had media attachments (images, files), only the text block is extracted. The attachments are lost without any indication. This needs a design decision: should these messages be excluded from history? Should the text be recalled with a note that attachments were omitted? Or should attachments be fully restored?

} else if (Array.isArray(content)) {
const textBlock = content.find(b => b.type === 'text')
if (textBlock?.text) texts.push(textBlock.text)
}
}
return texts
}


🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Replace inline prompt history navigation with a popup picker triggered
by Alt+PageUp. Shows past user messages in chronological order with
search filtering and two actions per item: insert at cursor (default,
Enter) and replace (Tab+Enter). Insert button is visually pre-highlighted
when navigating the list. Add Alt+PgUp hint to textarea placeholder.
@twidi
Copy link
Owner

twidi commented Mar 11, 2026

Follow-up review (second commit)

The second commit (2f58d4e) reworks the approach into a popup picker, which addresses the bugs from my previous comment (missing adjustTextareaHeight, missing WC force-update, stale historyIndex). The new onHistoryReplace and onHistoryInsert handlers correctly follow the file's established patterns. Nice work on that front.

Two new issues to flag:


9. Prompt history only shows messages whose content has been loaded — non-deterministic results

loadMessages() iterates store.getSessionItems(sessionId) and calls getParsedContent() on each user_message item. However, session items are loaded in two phases:

  1. Metadata for all itemsinitSessionItemsFromMetadata() creates entries with kind set but content: null
  2. Content for only the last 100 itemsupdateSessionItemsContent() fills in content for the initial viewport (INITIAL_ITEMS_COUNT = 100). Remaining items get their content lazy-loaded only as the user scrolls through the virtual scroller.

When getParsedContent() is called on an item with content: null, it returns null, and the message is silently skipped. This means:

  • In a session with 500 items, the popup will only show user messages from roughly the last 100 items
  • If the user has scrolled up earlier (triggering lazy loads for older items), those messages will also appear
  • The history shown is non-deterministic — it depends on what the user has previously scrolled through

The popup should either fetch all user message content from the API when it opens, or clearly indicate that only recent messages are available. Neither option is ideal — the first adds a potentially expensive API call for a secondary feature, while the second surfaces an internal implementation detail as a visible UX limitation. This may be worth a design discussion before settling on an approach.

function loadMessages() {
const items = store.getSessionItems(props.sessionId)
const messages = []
for (let i = 0; i < items.length; i++) {
if (items[i].kind !== 'user_message') continue
const parsed = getParsedContent(items[i])
const content = parsed?.message?.content
if (!content) continue
let text = ''
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
const textBlock = content.find(b => b.type === 'text')
if (textBlock?.text) text = textBlock.text
}
if (text) messages.push(text)
}
allMessages.value = messages
}


10. Mobile support: the feature appears unreachable on touch devices

A few questions about how this works on mobile:

  • Opening the popup: The only trigger is Alt+PageUp, which doesn't exist on mobile keyboards. The placeholder mentions the shortcut, but there is no touch-accessible button or gesture to open the picker. Is there a plan for a toolbar button or alternative trigger?

text += `, Alt+PgUp for history, ${keys} to send`

  • Selecting an item: There is no @click handler on the item row itself — the user must click one of the two action buttons (insert or replace). These buttons have opacity: 0 and only become visible on hover (.picker-item:hover .item-actions) or when the item is active (.item-actions.visible). On touch devices there is no hover, and activeIndex is only updated via @mouseenter (which doesn't fire on tap), so the buttons remain invisible and unreachable.

:data-index="index"
class="picker-item"
:class="{ active: index === activeIndex }"
@mouseenter="activeIndex = index"
>
<div class="item-text">{{ msg }}</div>
<div class="item-actions" :class="{ visible: index === activeIndex }">
<button
class="action-btn action-insert"
:class="{ 'pre-selected': index === activeIndex && focusTarget === 'list' }"
title="Insert (at cursor position) — Enter"
tabindex="-1"
@click.stop="insertMessage(msg)"
@keydown="handleActionKeydown($event, 'insert')"
>
<wa-icon name="right-to-bracket"></wa-icon>
</button>
<button
class="action-btn action-replace"
title="Replace (overwrite current text) — Tab+Enter"
tabindex="-1"
@click.stop="replaceMessage(msg)"
@keydown="handleActionKeydown($event, 'replace')"
>
<wa-icon name="right-left"></wa-icon>

.item-actions {
display: flex;
gap: var(--wa-space-3xs);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s;
}
.item-actions.visible,
.picker-item:hover .item-actions {
opacity: 1;
}

  • Navigating items: activeIndex is set by @mouseenter or keyboard arrows — neither works naturally on touch. The user would have no way to change which item is active.

If this is intentionally desktop/keyboard-only, it might be worth documenting. If not, at minimum a tap on an item row should trigger the default action (insert), and there should be an alternative way to open the popup (e.g., a toolbar button).


🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Add GET /api/.../user-messages/ endpoint that returns paginated user
message texts with offset/limit, search filtering (q param), and both
filtered index and original chronological index in the response.

Rewrite the prompt history popup to use virtual scrolling: fixed-height
items positioned absolutely in a sized viewport, pages of 100 fetched
on demand as the user scrolls, with debounced server-side search.
Add a toolbar button (clock-rotate-left icon) to open the prompt
history picker, making it accessible on touch devices without
Alt+PageUp. Add tap-to-select interaction: first tap activates an
item and shows action buttons, second tap inserts. Uses a confirmed
index to prevent immediate insertion on first tap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants