diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e594863abd0..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, @@ -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) } 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..ea727994271 --- /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 }