summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-13 06:51:24 -0600
committerAdam <[email protected]>2025-12-13 14:57:24 -0600
commita6e297baadc6c19a566a69da92c7fb2010f40977 (patch)
treed71e80f8a8f60e5ca6683c4a64fd3aa46c8e7c3b /packages
parent307af10c8bad1eb90288df5447275bb1b65cebc7 (diff)
downloadopencode-a6e297baadc6c19a566a69da92c7fb2010f40977.tar.gz
opencode-a6e297baadc6c19a566a69da92c7fb2010f40977.zip
feat(desktop): message history
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx161
-rw-r--r--packages/ui/src/components/button.css2
-rw-r--r--packages/ui/src/components/session-turn.tsx3
-rw-r--r--packages/ui/src/styles/animations.css4
4 files changed, 165 insertions, 5 deletions
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<PromptInputProps> = (props) => {
popoverIsOpen: false,
})
+ const MAX_HISTORY = 100
+ const [history, setHistory] = makePersisted(
+ createStore<{
+ entries: Prompt[]
+ }>({
+ entries: [],
+ }),
+ {
+ name: "prompt-history.v1",
+ },
+ )
+ const [historyIndex, setHistoryIndex] = createSignal<number>(-1)
+ const [savedPrompt, setSavedPrompt] = createSignal<Prompt | null>(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<PromptInputProps> = (props) => {
setStore("popoverIsOpen", false)
}
+ if (historyIndex() >= 0) {
+ setHistoryIndex(-1)
+ setSavedPrompt(null)
+ }
+
session.prompt.set(rawParts, cursorPosition)
}
@@ -296,12 +365,100 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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(
</div>
</div>
</div>
- {/* User Message (non-sticky, scrolls under sticky header) */}
+ {/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={message()} parts={parts()} />
</div>
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;
}
}