From ce500306347410e4041938afd5962d35c941bcb4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 5 Jun 2026 20:33:53 -0500 Subject: [PATCH] feat(desktop): integrate arrow history with the message queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/desktop/src/app/chat/composer/index.tsx | 143 ++++++++++++------ .../src/store/composer-input-history.ts | 6 +- 2 files changed, 97 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e120bd8c9d5..0d0318af41a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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( diff --git a/apps/desktop/src/store/composer-input-history.ts b/apps/desktop/src/store/composer-input-history.ts index b86140a61d1..ea727994271 100644 --- a/apps/desktop/src/store/composer-input-history.ts +++ b/apps/desktop/src/store/composer-input-history.ts @@ -55,11 +55,11 @@ export function deriveUserHistory( 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)