diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 074a03582..ccff04efe 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -137,6 +137,8 @@ export const PromptInput: Component = (props) => { let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement + const mirror = { input: false } + const scrollCursorIntoView = () => { const container = scrollRef const selection = window.getSelection() @@ -651,6 +653,25 @@ export const PromptInput: Component = (props) => { () => prompt.current(), (currentParts) => { const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt + + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + renderEditor(inputParts) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + return + } + const domParts = parseFromDOM() if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return @@ -765,6 +786,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } if (prompt.dirty()) { + mirror.input = true prompt.set(DEFAULT_PROMPT, 0) } queueScroll() @@ -795,6 +817,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } + mirror.input = true prompt.set([...rawParts, ...images], cursorPosition) queueScroll() } diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index cd43c33c1..4dbb9e048 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -26,13 +26,17 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const view = createMemo(() => layout.view(sessionKey)) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const usd = createMemo( + () => + new Intl.NumberFormat(language.locale(), { + style: "currency", + currency: "USD", + }), + ) + const cost = createMemo(() => { - const locale = language.locale() const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return new Intl.NumberFormat(locale, { - style: "currency", - currency: "USD", - }).format(total) + return usd().format(total) }) const context = createMemo(() => { diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 4c672af3e..37733caff 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -26,6 +26,14 @@ export function SessionContextTab(props: SessionContextTabProps) { const sync = useSync() const language = useLanguage() + const usd = createMemo( + () => + new Intl.NumberFormat(language.locale(), { + style: "currency", + currency: "USD", + }), + ) + const ctx = createMemo(() => { const last = findLast(props.messages(), (x) => { if (x.role !== "assistant") return false @@ -62,12 +70,8 @@ export function SessionContextTab(props: SessionContextTabProps) { }) const cost = createMemo(() => { - const locale = language.locale() const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return new Intl.NumberFormat(locale, { - style: "currency", - currency: "USD", - }).format(total) + return usd().format(total) }) const counts = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a14b6bcf6..2016870e1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1251,19 +1251,40 @@ export default function Page() { autoScroll.forceScrollToBottom() } + const closestMessage = (node: Element | null): HTMLElement | null => { + if (!node) return null + const match = node.closest?.("[data-message-id]") as HTMLElement | null + if (match) return match + const root = node.getRootNode?.() + if (root instanceof ShadowRoot) return closestMessage(root.host) + return null + } + const getActiveMessageId = (container: HTMLDivElement) => { + const rect = container.getBoundingClientRect() + if (!rect.width || !rect.height) return + + const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2)) + const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100)) + + const hit = document.elementFromPoint(x, y) + const host = closestMessage(hit) + const id = host?.dataset.messageId + if (id) return id + + // Fallback: DOM query (handles edge hit-testing cases) const cutoff = container.scrollTop + 100 const nodes = container.querySelectorAll("[data-message-id]") - let id: string | undefined + let last: string | undefined for (const node of nodes) { const next = node.dataset.messageId if (!next) continue if (node.offsetTop > cutoff) break - id = next + last = next } - return id + return last } const scheduleScrollSpy = (container: HTMLDivElement) => { @@ -1900,6 +1921,8 @@ export default function Page() { const [positions, setPositions] = createSignal>({}) const [draftTop, setDraftTop] = createSignal(undefined) + const empty = {} as Record + const commentLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) const end = Math.max(range.start, range.end) @@ -1933,12 +1956,22 @@ export default function Page() { return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) } + const equal = (a: Record, b: Record) => { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (a[key] !== b[key]) return false + } + return true + } + const updateComments = () => { const el = wrap const root = getRoot() if (!el || !root) { - setPositions({}) - setDraftTop(undefined) + setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty)) + setDraftTop((prev) => (prev === undefined ? prev : undefined)) return } @@ -1949,7 +1982,7 @@ export default function Page() { next[comment.id] = markerTop(el, marker) } - setPositions(next) + setPositions((prev) => (equal(prev, next) ? prev : next)) const range = commenting() if (!range) { @@ -1963,11 +1996,18 @@ export default function Page() { return } - setDraftTop(markerTop(el, marker)) + const nextTop = markerTop(el, marker) + setDraftTop((prev) => (prev === nextTop ? prev : nextTop)) } + let commentFrame: number | undefined + const scheduleComments = () => { - requestAnimationFrame(updateComments) + if (commentFrame !== undefined) return + commentFrame = requestAnimationFrame(() => { + commentFrame = undefined + updateComments() + }) } createEffect(() => { @@ -2225,6 +2265,7 @@ export default function Page() { ) onCleanup(() => { + if (commentFrame !== undefined) cancelAnimationFrame(commentFrame) for (const item of codeScroll) { item.removeEventListener("scroll", handleCodeScroll) }