Align prompt persistence and Electron builds with Node toolchain

This commit is contained in:
Shantur Rathore
2025-10-31 16:01:29 +00:00
parent a86825569d
commit 40832ec1b6
10 changed files with 393 additions and 73 deletions

View File

@@ -84,7 +84,8 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
setHtml(rendered)
part.renderCache = { text, html: rendered }
const themeKey = Boolean(props.isDark) ? "dark" : "light"
part.renderCache = { text, html: rendered, theme: themeKey }
}
} catch (error) {
console.error("Failed to re-render markdown after language load:", error)

View File

@@ -1,7 +1,8 @@
import { createSignal, Show, onMount, For, onCleanup } from "solid-js"
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
import UnifiedPicker from "./unified-picker"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { getPromptValue, setPromptValue, clearPromptValue } from "../stores/prompt-state"
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
import type { Attachment } from "../types/attachment"
import Kbd from "./kbd"
@@ -19,7 +20,7 @@ interface PromptInputProps {
}
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPrompt] = createSignal("")
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [isFocused, setIsFocused] = createSignal(false)
@@ -27,7 +28,7 @@ export default function PromptInput(props: PromptInputProps) {
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set())
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
let textareaRef: HTMLTextAreaElement | undefined
@@ -36,6 +37,85 @@ export default function PromptInput(props: PromptInputProps) {
const attachments = () => getAttachments(props.instanceId, props.sessionId)
const instanceAgents = () => agents().get(props.instanceId) || []
const setPrompt = (value: string) => {
setPromptInternal(value)
setPromptValue(props.instanceId, props.sessionId, value)
}
const clearPrompt = () => {
clearPromptValue(props.instanceId, props.sessionId)
setPromptInternal("")
}
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
let highestPaste = 0
let highestImage = 0
for (const match of currentPrompt.matchAll(/\[pasted #(\d+)\]/g)) {
const value = Number.parseInt(match[1], 10)
if (!Number.isNaN(value)) {
highestPaste = Math.max(highestPaste, value)
}
}
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(/pasted #(\d+)/)
if (placeholderMatch) {
const value = Number.parseInt(placeholderMatch[1], 10)
if (!Number.isNaN(value)) {
highestPaste = Math.max(highestPaste, value)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(/Image #(\d+)/)
if (imageMatch) {
const value = Number.parseInt(imageMatch[1], 10)
if (!Number.isNaN(value)) {
highestImage = Math.max(highestImage, value)
}
}
}
}
for (const match of currentPrompt.matchAll(/\[Image #(\d+)\]/g)) {
const value = Number.parseInt(match[1], 10)
if (!Number.isNaN(value)) {
highestImage = Math.max(highestImage, value)
}
}
setPasteCount(highestPaste)
setImageCount(highestImage)
}
createEffect(
on(
() => `${props.instanceId}:${props.sessionId}`,
() => {
const storedPrompt = getPromptValue(props.instanceId, props.sessionId)
const currentAttachments = untrack(() => getAttachments(props.instanceId, props.sessionId))
setPrompt(storedPrompt)
setHistoryIndex(-1)
setIgnoredAtPositions(new Set<number>())
setShowPicker(false)
setAtPosition(null)
setSearchQuery("")
syncAttachmentCounters(storedPrompt, currentAttachments)
queueMicrotask(() => {
if (textareaRef) {
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
}
})
},
{ defer: true },
),
)
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId)
@@ -391,7 +471,7 @@ export default function PromptInput(props: PromptInputProps) {
const currentAttachments = attachments()
if (!text || props.disabled) return
setPrompt("")
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
setPasteCount(0)

View File

@@ -0,0 +1,34 @@
import { createSignal } from "solid-js"
function getSessionKey(instanceId: string, sessionId: string): string {
return `${instanceId}:${sessionId}`
}
const [prompts, setPrompts] = createSignal<Map<string, string>>(new Map())
export function getPromptValue(instanceId: string, sessionId: string): string {
const key = getSessionKey(instanceId, sessionId)
return prompts().get(key) || ""
}
export function setPromptValue(instanceId: string, sessionId: string, value: string) {
const key = getSessionKey(instanceId, sessionId)
setPrompts((prev) => {
const next = new Map(prev)
if (value.length === 0) {
next.delete(key)
} else {
next.set(key, value)
}
return next
})
}
export function clearPromptValue(instanceId: string, sessionId: string) {
const key = getSessionKey(instanceId, sessionId)
setPrompts((prev) => {
const next = new Map(prev)
next.delete(key)
return next
})
}

View File

@@ -5,6 +5,7 @@ import { partHasRenderableText } from "../types/message"
import { instances } from "./instances"
import { sseManager } from "../lib/sse-manager"
import { decodeHtmlEntities } from "../lib/markdown"
import { preferences } from "./preferences"
interface SessionInfo {
@@ -29,6 +30,13 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
if (typeof globalThis !== "undefined") {
const debugGlobal = globalThis as any
debugGlobal.__OPENCODE_DEBUG__ = {
...(debugGlobal.__OPENCODE_DEBUG__ ?? {}),
getSessions: () => sessions(),
}
}
// Message index cache structure: instanceId -> sessionId -> { messageIndex, partIndex }
const sessionIndexes = new Map<
@@ -36,6 +44,78 @@ const sessionIndexes = new Map<
Map<string, { messageIndex: Map<string, number>; partIndex: Map<string, Map<string, number>> }>
>()
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
return decodeHtmlEntities(segment)
}
if (segment && typeof segment === "object") {
const updated: Record<string, any> = { ...segment }
if (typeof updated.text === "string") {
updated.text = decodeHtmlEntities(updated.text)
}
if (typeof updated.value === "string") {
updated.value = decodeHtmlEntities(updated.value)
}
if (Array.isArray(updated.content)) {
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
}
return updated
}
return segment
}
function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
if (part.type !== "text") {
return part
}
const normalized: Record<string, any> = { ...part, renderCache: undefined }
if (typeof normalized.text === "string") {
normalized.text = decodeHtmlEntities(normalized.text)
} else if (normalized.text && typeof normalized.text === "object") {
const textObject: Record<string, any> = { ...normalized.text }
if (typeof textObject.value === "string") {
textObject.value = decodeHtmlEntities(textObject.value)
}
if (Array.isArray(textObject.content)) {
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
}
if (typeof textObject.text === "string") {
textObject.text = decodeHtmlEntities(textObject.text)
}
normalized.text = textObject
}
if (Array.isArray(normalized.content)) {
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
}
if (normalized.thinking && typeof normalized.thinking === "object") {
const thinking: Record<string, any> = { ...normalized.thinking }
if (Array.isArray(thinking.content)) {
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
}
normalized.thinking = thinking
}
return normalized
}
function getSessionIndex(instanceId: string, sessionId: string) {
let instanceMap = sessionIndexes.get(instanceId)
if (!instanceMap) {
@@ -716,13 +796,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
messagesInfo.set(messageId, info)
// Clear render cache for all parts when loading messages
const parts = (apiMessage.parts || []).map((part: any) => {
if (part.type === "text") {
return { ...part, renderCache: undefined }
}
return part
})
// Normalize parts to decode entities and clear caches for text segments
const parts = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
const message: Message = {
id: messageId,
@@ -815,8 +890,10 @@ function handleMessageUpdate(instanceId: string, event: any): void {
if (!instanceSessions) return
if (event.type === "message.part.updated") {
const part = event.properties?.part
if (!part) return
const rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const session = instanceSessions.get(part.sessionID)
if (!session) return

View File

@@ -216,16 +216,6 @@
line-height: var(--line-height-normal);
}
/* Assistant message compact overrides */
.message-text-assistant {
font-size: 13.5px;
line-height: 1.1;
}
.message-text-assistant .prose {
font-size: 0.94rem;
}
.message-text-assistant .prose p {
margin: 0;
}