diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 70ee0a739..ec8267bf7 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -13,6 +13,7 @@ import { createMemo, } from "solid-js" import { createStore } from "solid-js/store" +import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" @@ -85,6 +86,69 @@ export const PromptInput: Component = (props) => { popoverIsOpen: false, }) + const MAX_HISTORY = 100 + const [history, setHistory] = makePersisted( + createStore<{ + entries: Prompt[] + }>({ + entries: [], + }), + { + name: "prompt-history.v1", + }, + ) + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [savedPrompt, setSavedPrompt] = createSignal(null) + + const clonePromptParts = (prompt: Prompt): Prompt => + prompt.map((part) => + part.type === "text" + ? { ...part } + : { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + }, + ) + + const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + + const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(prompt) + session.prompt.set(prompt, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + }) + } + + const getCaretLineState = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + const editorRect = editorRef.getBoundingClientRect() + const style = window.getComputedStyle(editorRef) + const paddingTop = parseFloat(style.paddingTop) || 0 + const paddingBottom = parseFloat(style.paddingBottom) || 0 + let lineHeight = parseFloat(style.lineHeight) + if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 + const scrollTop = editorRef.scrollTop + let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop + if (!Number.isFinite(relativeTop)) relativeTop = scrollTop + relativeTop = Math.max(0, relativeTop) + let caretHeight = rect.height + if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight + const relativeBottom = relativeTop + caretHeight + const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) + const threshold = Math.max(2, lineHeight / 2) + + return { + collapsed: selection.isCollapsed, + onFirstLine: relativeTop <= threshold, + onLastLine: contentHeight - relativeBottom <= threshold, + } + } + const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length)) onMount(() => { @@ -221,6 +285,11 @@ export const PromptInput: Component = (props) => { setStore("popoverIsOpen", false) } + if (historyIndex() >= 0) { + setHistoryIndex(-1) + setSavedPrompt(null) + } + session.prompt.set(rawParts, cursorPosition) } @@ -296,12 +365,100 @@ export const PromptInput: Component = (props) => { sessionID: session.id!, }) + const addToHistory = (prompt: Prompt) => { + const text = prompt + .map((p) => p.content) + .join("") + .trim() + if (!text) return + + const entry = clonePromptParts(prompt) + const lastEntry = history.entries[0] + if (lastEntry) { + const lastText = lastEntry.map((p) => p.content).join("") + if (lastText === text) return + } + + setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + } + + const navigateHistory = (direction: "up" | "down") => { + const entries = history.entries + const current = historyIndex() + + if (direction === "up") { + if (entries.length === 0) return false + if (current === -1) { + setSavedPrompt(clonePromptParts(session.prompt.current())) + setHistoryIndex(0) + applyHistoryPrompt(entries[0], "start") + return true + } + if (current < entries.length - 1) { + const next = current + 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "start") + return true + } + return false + } + + if (current > 0) { + const next = current - 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "end") + return true + } + if (current === 0) { + setHistoryIndex(-1) + const saved = savedPrompt() + if (saved) { + applyHistoryPrompt(saved, "end") + setSavedPrompt(null) + return true + } + applyHistoryPrompt(DEFAULT_PROMPT, "end") + return true + } + + return false + } + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) event.preventDefault() return } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (!collapsed) return + const cursorPos = getCursorPosition(editorRef) + const textLength = promptLength(session.prompt.current()) + const inHistory = historyIndex() >= 0 + const isStart = cursorPos === 0 + const isEnd = cursorPos === textLength + const atAbsoluteStart = onFirstLine && isStart + const atAbsoluteEnd = onLastLine && isEnd + const allowUp = (inHistory && isEnd) || atAbsoluteStart + const allowDown = (inHistory && isStart) || atAbsoluteEnd + + if (event.key === "ArrowUp") { + if (!allowUp) return + if (navigateHistory("up")) { + event.preventDefault() + } + return + } + + if (!allowDown) return + if (navigateHistory("down")) { + event.preventDefault() + } + return + } + if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -323,6 +480,10 @@ export const PromptInput: Component = (props) => { return } + addToHistory(prompt) + setHistoryIndex(-1) + setSavedPrompt(null) + let existing = session.info() if (!existing) { const created = await sdk.client.session.create() diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index c5bd2c696..7aba89b03 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -148,7 +148,7 @@ padding: 0 12px 0 8px; } - gap: 4px; + gap: 8px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 361a5cac0..07946ed79 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -81,7 +81,6 @@ export function SessionTurn( createResizeObserver(contentRef, () => { if (!scrollRef || userScrolled() || !working()) return requestAnimationFrame(() => { - if (!scrollRef) return scrollRef.scrollTop = scrollRef.scrollHeight }) }) @@ -266,7 +265,7 @@ export function SessionTurn( - {/* User Message (non-sticky, scrolls under sticky header) */} + {/* User Message */}
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 0ae3493eb..3480976dd 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -5,7 +5,7 @@ @keyframes pulse-opacity { 0%, 100% { - opacity: 0; + opacity: 0.4; } 50% { opacity: 1; @@ -18,7 +18,7 @@ opacity: 0; } 50% { - opacity: 0.3; + opacity: 0.2; } }