feat(desktop): arrow up/down to navigate previous user messages
This commit is contained in:
parent
0cbcc75935
commit
f94363d1f0
@ -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)
|
||||
}
|
||||
|
||||
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…
x
Reference in New Issue
Block a user