From f94363d1f0ded71c4b146725e1c1a1708e6482ad Mon Sep 17 00:00:00 2001 From: naqerl Date: Thu, 4 Jun 2026 13:37:53 +0000 Subject: [PATCH 1/2] feat(desktop): arrow up/down to navigate previous user messages --- apps/desktop/src/app/chat/composer/index.tsx | 81 +++++++++ .../src/store/composer-input-history.test.ts | 147 ++++++++++++++++ .../src/store/composer-input-history.ts | 158 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 apps/desktop/src/store/composer-input-history.test.ts create mode 100644 apps/desktop/src/store/composer-input-history.ts diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e594863abd0..e120bd8c9d5 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -33,6 +33,13 @@ 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' @@ -124,6 +131,7 @@ export function ChatBar({ const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) const scrolledUp = useStore($threadScrolledUp) + const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null const queuedPrompts = useMemo( @@ -247,6 +255,16 @@ 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 @@ -715,6 +733,66 @@ 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. + 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. + if (currentDraft.trim() && !isBrowsingHistory(sessionId)) { + return + } + + event.preventDefault() + triggerKeyConsumedRef.current = true + + const history = deriveUserHistory(sessionMessages, chatMessageText) + 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) + } + } + + return + } + + if (event.key === 'ArrowDown') { + if (isBrowsingHistory(sessionId)) { + event.preventDefault() + triggerKeyConsumedRef.current = true + + const history = deriveUserHistory(sessionMessages, chatMessageText) + 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) + } + } + + return + } + + // Not browsing — let the browser handle default cursor movement. + return + } + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() @@ -973,6 +1051,7 @@ export function ChatBar({ } removeQueuedPrompt(activeQueueSessionKey, entry.id) + resetBrowseState(sessionId) return true } finally { @@ -1077,6 +1156,7 @@ export function ChatBar({ } else if (draft.trim() || attachments.length > 0) { const submitted = draft triggerHaptic('submit') + resetBrowseState(sessionId) clearDraft() clearComposerAttachments() void onSubmit(submitted, { attachments }) @@ -1146,6 +1226,7 @@ export function ChatBar({ } triggerHaptic('submit') + resetBrowseState(sessionId) clearDraft() await onSubmit(text) } diff --git a/apps/desktop/src/store/composer-input-history.test.ts b/apps/desktop/src/store/composer-input-history.test.ts new file mode 100644 index 00000000000..53af5aea442 --- /dev/null +++ b/apps/desktop/src/store/composer-input-history.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + $perSessionBrowse, + browseBackward, + browseForward, + deriveUserHistory, + isBrowsingHistory, + resetBrowseState +} from './composer-input-history' + +const SESSION_A = 'session-a' +const SESSION_B = 'session-b' + +// Newest-first user text ring, what the caller passes to browse*. +const HISTORY = ['third', 'second', 'first'] + +const MSG = (role: string, text: string) => ({ id: '', role, text }) + +beforeEach(() => { + $perSessionBrowse.set({}) +}) + +describe('deriveUserHistory', () => { + it('returns user messages newest-first with empty/whitespace skipped', () => { + const messages = [ + MSG('user', ' '), + MSG('assistant', 'hi'), + MSG('user', 'first'), + MSG('user', 'second') + ] + + expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first']) + }) +}) + +describe('browseBackward', () => { + it('returns null when history is empty', () => { + expect(browseBackward(SESSION_A, '', [])).toBeNull() + }) + + it('returns the most recent entry on first press and saves the draft', () => { + const result = browseBackward(SESSION_A, 'unsent draft', HISTORY) + + expect(result).toBe('third') + expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft') + }) + + it('moves to older entries on subsequent presses and stops at the oldest', () => { + expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third') + expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second') + expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first') + expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull() + }) + + it('uses caller-provided history, not a mirrored ring', () => { + // The store never owns the ring — the caller passes it every press. + // If the ring changes between presses (e.g. a new message was sent), + // the next press sees the updated ring and the cursor continues + // from where it was within it. + expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest') + + // Caller added a new message; ring is now [brand-new, youngest, older]. + // Cursor was at 0, next press advances to 1 -> "youngest". + expect( + browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older']) + ).toBe('youngest') + + // One more press -> "older". + expect( + browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older']) + ).toBe('older') + }) +}) + +describe('browseForward', () => { + it('returns null when not browsing', () => { + expect(browseForward(SESSION_A, HISTORY)).toBeNull() + }) + + it('moves toward the present', () => { + browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third' + browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second' + + expect(browseForward(SESSION_A, HISTORY)).toEqual({ + text: 'third', + returnedToPresent: false + }) + }) + + it('restores the saved draft and resets when reaching the present', () => { + browseBackward(SESSION_A, 'my original draft', HISTORY) + + const result = browseForward(SESSION_A, HISTORY) + + expect(result).toEqual({ text: 'my original draft', returnedToPresent: true }) + expect(isBrowsingHistory(SESSION_A)).toBe(false) + }) +}) + +describe('per-session isolation', () => { + it('tracks cursor and draft independently per session', () => { + browseBackward(SESSION_A, 'draft-a', HISTORY) + browseBackward(SESSION_A, '', HISTORY) // older + + browseBackward(SESSION_B, 'draft-b', HISTORY) + + const a = $perSessionBrowse.get()[SESSION_A]! + const b = $perSessionBrowse.get()[SESSION_B]! + + expect(a.cursor).toBe(1) + expect(a.draftSnapshot).toBe('draft-a') + expect(b.cursor).toBe(0) + expect(b.draftSnapshot).toBe('draft-b') + }) +}) + +describe('resetBrowseState', () => { + it('clears cursor and draft snapshot', () => { + browseBackward(SESSION_A, 'draft', HISTORY) + resetBrowseState(SESSION_A) + + const s = $perSessionBrowse.get()[SESSION_A]! + + expect(s.cursor).toBe(-1) + expect(s.draftSnapshot).toBe('') + }) +}) + +describe('session switch behavior', () => { + it('resets the previous session cursor and lets the new session derive its own ring', () => { + // Session A: user browsed into the past + browseBackward(SESSION_A, '', HISTORY) + expect(isBrowsingHistory(SESSION_A)).toBe(true) + + // Caller switches to session B; resets A's browse state + resetBrowseState(SESSION_A) + + // Session B's ring is derived from B's messages, not A's + const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')] + const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text) + + expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b') + expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b') + expect(isBrowsingHistory(SESSION_A)).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/composer-input-history.ts b/apps/desktop/src/store/composer-input-history.ts new file mode 100644 index 00000000000..b86140a61d1 --- /dev/null +++ b/apps/desktop/src/store/composer-input-history.ts @@ -0,0 +1,158 @@ +import { atom } from 'nanostores' + +/** + * Per-session input history browse state. + * + * The user-text ring is **derived from the live session messages** on each + * keypress — it is not mirrored anywhere. This keeps a single source of truth + * and avoids the entire class of seeding/dedup bugs that come from trying to + * keep a parallel ring in sync with submit/queue/voice paths. + * + * We only persist the cursor and the saved draft: + * - `cursor` — index into the derived user-text ring (0 = newest, larger = older). + * `-1` means "not browsing". + * - `draftSnapshot` — the composer text at the moment the user started + * browsing, so ArrowDown back to the "present" restores it. + */ +export interface SessionBrowseState { + cursor: number + draftSnapshot: string +} + +const $perSessionBrowse = atom>({}) + +function ensure(sessionId: string): SessionBrowseState { + const all = { ...$perSessionBrowse.get() } + let s = all[sessionId] + + if (!s) { + s = { cursor: -1, draftSnapshot: '' } + all[sessionId] = s + $perSessionBrowse.set(all) + } + + return s +} + +function persist() { + $perSessionBrowse.set({ ...$perSessionBrowse.get() }) +} + +function valid(sessionId: string | null | undefined): sessionId is string { + return typeof sessionId === 'string' && sessionId.length > 0 +} + +/** + * Derive the user-text ring (newest first) from session messages. + * The caller is responsible for providing already-session-scoped messages. + */ +export function deriveUserHistory( + messages: readonly T[], + getText: (m: T) => string +): string[] { + const out: string[] = [] + + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]! + + if (m.role !== 'user') continue + + const t = getText(m).trim() + + if (t) out.push(t) + } + + return out +} + +/** + * Start browsing backward, or step to the next older entry. + * Returns the text to place in the composer, or null if already at the oldest + * entry (or the ring is empty). + */ +export function browseBackward( + sessionId: string | null | undefined, + currentDraft: string, + history: readonly string[] +): string | null { + if (!valid(sessionId) || history.length === 0) { + return null + } + + const s = ensure(sessionId) + + if (s.cursor === -1) { + s.draftSnapshot = currentDraft + s.cursor = 0 + } else if (s.cursor < history.length - 1) { + s.cursor += 1 + } else { + return null + } + + persist() + + return history[s.cursor]! +} + +/** + * Browse forward toward the present. When reaching the "newest" entry the + * saved draft is restored and the cursor resets. + */ +export function browseForward( + sessionId: string | null | undefined, + history: readonly string[] +): { text: string; returnedToPresent: boolean } | null { + if (!valid(sessionId)) { + return null + } + + const s = ensure(sessionId) + + if (s.cursor === -1) { + return null + } + + if (s.cursor > 0) { + s.cursor -= 1 + persist() + + return { text: history[s.cursor]!, returnedToPresent: false } + } + + // At newest; moving forward restores the saved draft. + const text = s.draftSnapshot + s.cursor = -1 + s.draftSnapshot = '' + persist() + + return { text, returnedToPresent: true } +} + +/** Clear browse state for a session (e.g. on session switch or new submit). */ +export function resetBrowseState(sessionId: string | null | undefined) { + if (!valid(sessionId)) { + return + } + + const all = { ...$perSessionBrowse.get() } + const existing = all[sessionId] + + if (!existing) return + + all[sessionId] = { cursor: -1, draftSnapshot: '' } + $perSessionBrowse.set(all) +} + +/** True if the user is currently browsing history for this session. */ +export function isBrowsingHistory(sessionId: string | null | undefined): boolean { + if (!valid(sessionId)) { + return false + } + + const s = $perSessionBrowse.get()[sessionId] + + return s ? s.cursor >= 0 : false +} + +export { $perSessionBrowse } From ce500306347410e4041938afd5962d35c941bcb4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 5 Jun 2026 20:33:53 -0500 Subject: [PATCH 2/2] 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)