feat(desktop): integrate arrow history with the message queue

Builds on @naqerl's arrow up/down history (previous commit), making
ArrowUp do the right thing when a queue exists.

ArrowUp/ArrowDown priority:
1. Editing a queued turn → walk older/newer through queued entries,
   saving each edit; ArrowDown past the newest exits and restores the
   pre-edit draft.
2. Empty composer + queued turns → ArrowUp opens the newest queued entry
   for editing (the row's pencil), so Enter saves it back to the queue
   instead of firing a new message — the gap the history nav had alone.
3. Otherwise → sent-message history recall (unchanged).

Also: Esc cancels an in-progress queue edit (else interrupts).

Cleanups on the integrated code: fold the browse-state reset into the
existing session-change effect (drop the duplicate ref+effect); reuse
loadIntoComposer for history recall; sort imports; add curly braces +
the runDrain sessionId dep (lint).
This commit is contained in:
Brooklyn Nicholson 2026-06-05 20:33:53 -05:00
parent f94363d1f0
commit ce50030634
2 changed files with 97 additions and 52 deletions

View file

@ -24,6 +24,13 @@ import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@ -33,13 +40,6 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import {
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
import { $gatewayState, $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
@ -201,6 +201,7 @@ export function ChatBar({
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
@ -255,16 +256,6 @@ export function ChatBar({
}
}, [disabled, focusInput, focusKey, focusRequestId])
// Reset input history browse state when the active session changes.
const prevSessionRef = useRef(sessionId)
useEffect(() => {
if (prevSessionRef.current !== sessionId) {
prevSessionRef.current = sessionId
resetBrowseState(sessionId)
}
}, [sessionId])
useEffect(() => {
if (disabled) {
return undefined
@ -733,14 +724,31 @@ export function ChatBar({
}
}
// ArrowUp/ArrowDown for input history navigation. The user-text ring is
// derived from the live session messages on every press — no mirror, no
// seeding, no dedup. Single source of truth: $messages.
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
if (event.key === 'ArrowUp') {
const currentDraft = draftRef.current
// Don't hijack Arrow Up when the user has an unsent draft and isn't
// already browsing — they'd lose what they typed.
// Editing a queued turn → walk to the older entry.
if (queueEdit && stepQueuedEdit(-1)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
// Empty composer + a queued turn → open the newest queued entry for edit
// (the row's pencil), not a text recall. Enter saves it back to the queue.
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
event.preventDefault()
triggerKeyConsumedRef.current = true
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
return
}
// Don't hijack a typed draft unless already browsing — they'd lose it.
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
return
}
@ -752,21 +760,23 @@ export function ChatBar({
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
const editor = editorRef.current
draftRef.current = entry
aui.composer().setText(entry)
if (editor) {
renderComposerContents(editor, entry)
placeCaretEnd(editor)
}
loadIntoComposer(entry, $composerAttachments.get())
}
return
}
if (event.key === 'ArrowDown') {
// Editing a queued turn → walk to the newer entry (past the newest exits).
if (queueEdit) {
event.preventDefault()
triggerKeyConsumedRef.current = true
stepQueuedEdit(1)
return
}
// Browsing sent history → step toward the present, restoring the draft.
if (isBrowsingHistory(sessionId)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
@ -775,21 +785,10 @@ export function ChatBar({
const result = browseForward(sessionId, history)
if (result !== null) {
const editor = editorRef.current
draftRef.current = result.text
aui.composer().setText(result.text)
if (editor) {
renderComposerContents(editor, result.text)
placeCaretEnd(editor)
}
loadIntoComposer(result.text, $composerAttachments.get())
}
return
}
// Not browsing — let the browser handle default cursor movement.
return
}
@ -813,11 +812,21 @@ export function ChatBar({
return
}
// Esc interrupts the running turn (Stop-button parity).
if (event.key === 'Escape' && busy) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
if (event.key === 'Escape') {
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
if (queueEdit) {
event.preventDefault()
exitQueuedEdit('cancel')
return
}
// Otherwise Esc interrupts the running turn (Stop-button parity).
if (busy) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
}
}
@ -983,6 +992,42 @@ export function ChatBar({
focusInput()
}
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
// saving the in-progress edit on each step. Stepping newer past the last
// entry exits edit mode and restores the pre-edit draft.
const stepQueuedEdit = (direction: -1 | 1) => {
if (!queueEdit) {
return false
}
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
const target = index + direction
if (index < 0 || target < 0) {
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
attachments: cloneAttachments($composerAttachments.get()),
text: draftRef.current
})
const next = queuedPrompts[target]
if (next) {
setQueueEdit({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.text, next.attachments)
} else {
setQueueEdit(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
triggerHaptic(saved ? 'success' : 'selection')
focusInput()
return true
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) {
return false
@ -1058,7 +1103,7 @@ export function ChatBar({
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts]
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const drainNextQueued = useCallback(

View file

@ -55,11 +55,11 @@ export function deriveUserHistory<T extends { role: string }>(
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role !== 'user') continue
if (m.role !== 'user') {continue}
const t = getText(m).trim()
if (t) out.push(t)
if (t) {out.push(t)}
}
return out
@ -138,7 +138,7 @@ export function resetBrowseState(sessionId: string | null | undefined) {
const all = { ...$perSessionBrowse.get() }
const existing = all[sessionId]
if (!existing) return
if (!existing) {return}
all[sessionId] = { cursor: -1, draftSnapshot: '' }
$perSessionBrowse.set(all)