mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
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:
commit
ac177cea87
3 changed files with 437 additions and 6 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
147
apps/desktop/src/store/composer-input-history.test.ts
Normal file
147
apps/desktop/src/store/composer-input-history.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
158
apps/desktop/src/store/composer-input-history.ts
Normal file
158
apps/desktop/src/store/composer-input-history.ts
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue