Merge pull request #40234 from NousResearch/bb/desktop-queue-arrow-edit-v2

feat(desktop): arrow-key history + queue editing in composer
This commit is contained in:
brooklyn! 2026-06-05 20:38:37 -05:00 committed by GitHub
commit ac177cea87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 437 additions and 6 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,
@ -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(
@ -193,6 +201,7 @@ export function ChatBar({
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
@ -715,6 +724,74 @@ export function ChatBar({
}
}
// 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
// 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
}
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
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
const history = deriveUserHistory(sessionMessages, chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, $composerAttachments.get())
}
}
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
@ -735,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())
}
}
}
@ -905,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
@ -973,13 +1096,14 @@ export function ChatBar({
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts]
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const drainNextQueued = useCallback(
@ -1077,6 +1201,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 +1271,7 @@ export function ChatBar({
}
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
await onSubmit(text)
}

View file

@ -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)
})
})

View file

@ -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<Record<string, SessionBrowseState>>({})
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<T extends { role: string }>(
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 }