Files
CodeNomad/packages/ui/src/components/prompt-input/usePromptState.ts
Shantur Rathore 612e50808a fix(ui): preserve draft across prompt history
Stop resetting history navigation on input so editing recalled entries doesn't wipe the bottom draft. Allow ArrowDown navigation while in history and persist the session draft only for fresh prompts.
2026-02-11 13:52:02 +00:00

204 lines
5.8 KiB
TypeScript

import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
import { addToHistory, getHistory } from "../../stores/message-history"
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
import { getLogger } from "../../lib/logger"
const log = getLogger("actions")
type GetTextarea = () => HTMLTextAreaElement | undefined | null
type PromptStateOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
onSessionDraftLoaded?: (draft: string) => void
}
type HistorySelectOptions = {
force?: boolean
isPickerOpen: boolean
getTextarea: GetTextarea
}
type PromptState = {
prompt: Accessor<string>
setPrompt: (value: string) => void
clearPrompt: () => void
draftLoadedNonce: Accessor<number>
history: Accessor<string[]>
historyIndex: Accessor<number>
historyDraft: Accessor<string | null>
resetHistoryNavigation: () => void
clearHistoryDraft: () => void
recordHistoryEntry: (entry: string) => Promise<void>
selectPreviousHistory: (options: HistorySelectOptions) => boolean
selectNextHistory: (options: HistorySelectOptions) => boolean
}
const HISTORY_LIMIT = 100
export function usePromptState(options: PromptStateOptions): PromptState {
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
const setPrompt = (value: string) => {
setPromptInternal(value)
// Persist drafts only when the user is at the "fresh" position (not browsing history).
// This keeps the bottom-of-history draft stable even if the user edits recalled history entries.
if (historyIndex() === -1) {
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
}
}
const clearPrompt = () => {
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
setPromptInternal("")
}
const resetHistoryNavigation = () => {
setHistoryIndex(-1)
setHistoryDraft(null)
}
const clearHistoryDraft = () => {
setHistoryDraft(null)
}
createEffect(
on(
() => `${options.instanceId()}:${options.sessionId()}`,
() => {
const instanceId = options.instanceId()
const sessionId = options.sessionId()
onCleanup(() => {
// Persist the previous session's draft when switching sessions.
setSessionDraftPrompt(instanceId, sessionId, prompt())
})
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
setPromptInternal(storedPrompt)
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
resetHistoryNavigation()
setDraftLoadedNonce((prev) => prev + 1)
options.onSessionDraftLoaded?.(storedPrompt)
},
),
)
onMount(() => {
void (async () => {
const loaded = await getHistory(options.instanceFolder())
setHistory(loaded)
})()
})
const recordHistoryEntry = async (entry: string) => {
try {
await addToHistory(options.instanceFolder(), entry)
setHistory((prev) => {
const next = [entry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
return next
})
setHistoryIndex(-1)
} catch (historyError) {
log.error("Failed to update prompt history:", historyError)
}
}
const canUseHistory = (selectOptions: HistorySelectOptions) => {
if (selectOptions.force) return true
if (selectOptions.isPickerOpen) return false
const textarea = selectOptions.getTextarea()
if (!textarea) return false
// Only require the cursor to be at the buffer start when *entering* history navigation.
// Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown
// regardless of cursor position (we focus the end of the entry).
if (historyIndex() !== -1) return true
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
}
const focusTextareaEnd = (getTextarea: GetTextarea) => {
const textarea = getTextarea()
if (!textarea) return
setTimeout(() => {
const next = getTextarea()
if (!next) return
const pos = next.value.length
next.setSelectionRange(pos, pos)
next.focus()
}, 0)
}
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
focusTextareaEnd(selectOptions.getTextarea)
return true
}
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
} else {
setHistoryIndex(-1)
const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId())
setPrompt(draft ?? "")
setHistoryDraft(null)
}
focusTextareaEnd(selectOptions.getTextarea)
return true
}
return {
prompt,
setPrompt,
clearPrompt,
draftLoadedNonce,
history,
historyIndex,
historyDraft,
resetHistoryNavigation,
clearHistoryDraft,
recordHistoryEntry,
selectPreviousHistory,
selectNextHistory,
}
}