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.
204 lines
5.8 KiB
TypeScript
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,
|
|
}
|
|
}
|