diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 0adfc632f1d..7c7fad0f377 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -528,7 +528,9 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) { }), ) - if (!active) return + // Emptying the editor already collapses its caret to the start, which is also its end. Any selection call, even a + // read, forces a layout, and a submit empties the editor from its key handler. + if (!active || prompt.every((part) => isAttachment(part) || (part.type === "text" && !part.content))) return const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(editor) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 902fab90361..ca9e127dad5 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -3,6 +3,20 @@ @import "@opencode/ui/styles/tokens"; @import "tw-animate-css"; +/* Set on the timeline viewport and changed on every frame while the composer region grows. Neither inherits, so a change + restyles the viewport alone instead of every row inside it. */ +@property --session-timeline-inset { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --session-timeline-fade-start { + syntax: ""; + inherits: false; + initial-value: 0px; +} + @font-face { font-family: "JetBrainsMono Nerd Font Mono"; src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx index e69661202fd..70471efc504 100644 --- a/packages/app/src/session/composer/queue-panel.tsx +++ b/packages/app/src/session/composer/queue-panel.tsx @@ -38,16 +38,20 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo (next) => { const prevLive = lastLive lastLive = next + if (next.length === 0) return // An in-place edit or reorder keeps the row count: a row whose ID it replaced swaps in place rather than closing, // while a row that was already closing keeps closing until its exit completes. const swap = next.length === prevLive.length && prevLive.length > 0 + if (!swap && prevLive.length > 0 && !props.hidden) next.forEach((row) => !prevLive.some((item) => item.id === row.id) && appearing.add(row.id)) + const kept = untrack(shown).filter( (row) => !swap || next.some((item) => item.id === row.id) || !prevLive.some((item) => item.id === row.id), ) + setShown(keepLeaving(kept, next)) }, { defer: true }, @@ -63,10 +67,14 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo stopCascade?.() stopCascade = undefined const step = queueStagger() + if (!now || was !== false || step <= 0 || prefersReducedMotion()) return queueMicrotask(() => { if (!open()) return - stopCascade = cascade([...listRef.querySelectorAll('[data-component="session-queue-row"]')], step) + stopCascade = cascade( + [...listRef.querySelectorAll('[data-component="session-queue-row"]')], + step, + ) }) }, { defer: true }, @@ -145,16 +153,16 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo } function keepLeaving(previous: T[], next: T[]) { - return previous.reduce( - (rows, row, index) => { - if (rows.some((item) => item.id === row.id)) return rows - const before = previous.slice(0, index).findLast((item) => rows.some((entry) => entry.id === item.id)) - const at = before ? rows.findIndex((item) => item.id === before.id) + 1 : 0 + const rows = [...next] - return [...rows.slice(0, at), row, ...rows.slice(at)] - }, - [...next], - ) + previous.forEach((row, index) => { + if (rows.some((item) => item.id === row.id)) return + const before = previous.slice(0, index).findLast((item) => rows.some((entry) => entry.id === item.id)) + const at = before ? rows.findIndex((item) => item.id === before.id) + 1 : 0 + rows.splice(at, 0, row) + }) + + return rows } function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) { @@ -179,14 +187,16 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu }, }) + // Not keyed: the queue rebuilds every row object whenever it changes, and a keyed row would remount, tooltips and + // all, each time a prompt is added or the server confirms one. return ( - + {(entry) => (
props.queue.edit(props.id)} > - {entry.text || - (entry.attachments ? language.plural("session.queue.attachments", entry.attachments) : "")} + {entry().text || + (entry().attachments ? language.plural("session.queue.attachments", entry().attachments) : "")} - + - {language.plural("session.queue.attachments", entry.attachments)} + {language.plural("session.queue.attachments", entry().attachments)}
@@ -234,14 +244,14 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu class="flex shrink-0 items-center gap-1.5" classList={{ "opacity-0 focus-within:opacity-100 group-hover/queue-row:opacity-100 [@media(hover:none)]:opacity-100": - !active() && !editing() && !entry.pending, + !active() && !editing() && !entry().pending, "pointer-events-none": props.queue.busy() || leaving(), }} > - + - + - + 0) el.style.marginBottom = "0px" - - if (open && prevMb > 0) el.style.marginTop = `${-prevMb}px` - } + // Writes only until the first frame: a grow usually starts inside an input handler that has just changed the DOM, + // where a read would force a layout that the frame then repeats. + if (open && !reduced) el.style.height = "0px" if ((withFade || reduced) && open) el.style.opacity = "0" - - // The content's height is read once here, then kept current by a ResizeObserver rather than read every frame. const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : undefined - const margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0 - - if (margin < 0) el.style.overflowClipMargin = `${-margin}px` - let full = child ? child.getBoundingClientRect().height + margin : 0 + let mb = 0 + let prevMb = 0 + let margin = 0 + let full = 0 + let measured = false + // The content's height is read once, on the first frame, then kept current by a ResizeObserver rather than read + // every frame. const observer = new ResizeObserver((entries) => { const size = entries[0]?.borderBoxSize[0]?.blockSize @@ -499,6 +489,19 @@ function grow( if (child) observer.observe(child) + // Runs in the frame loop's read phase, before any of the frame's writes. + const measure = () => { + if (measured || !el.isConnected) return + measured = true + mb = parseFloat(getComputedStyle(el).marginBottom) || 0 + prevMb = + (!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0 + margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0 + full = child ? child.getBoundingClientRect().height + margin : 0 + + if (!open) state.spring.snap(el.getBoundingClientRect().height) + } + const stop = frameLoop.add((dt) => { if (!el.isConnected) { observer.disconnect() @@ -507,6 +510,8 @@ function grow( return false } + if (margin < 0) el.style.overflowClipMargin = `${-margin}px` + const noMotion = prefersReducedMotion() const size = state.open ? full : 0 state.spring.set(size).step(dt) @@ -550,7 +555,7 @@ function grow( } return true - }) + }, measure) state.end = () => { stop() diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 9bc18cfec3c..ecda2466530 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -176,10 +176,20 @@ export function createSessionTimelineInteraction(session: SessionModel) { consumePendingMessage: (key) => layout.pendingMessage.consume(key), }) + let resumeFrame: (() => void) | undefined + const resume = (behavior?: ScrollBehavior) => { setState("messageID", undefined) pin() - scrollToEnd(behavior) + // Scrolls on the next frame, before it paints. A submit resumes from its key handler right after changing the + // DOM, where reading the scroll range would force a layout that the frame then repeats. + resumeFrame?.() + resumeFrame = frameLoop.add(() => { + resumeFrame = undefined + scrollToEnd(behavior) + + return false + }) clearMessageHash() if (scroller) scheduleScrollState(scroller) @@ -382,6 +392,8 @@ export function createSessionTimelineInteraction(session: SessionModel) { onCleanup(() => { if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) + resumeFrame?.() + scrollStateFrame?.() fillFrame?.() diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 281d427c4c0..4778d25dffb 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -616,6 +616,11 @@ export function createTimelineVirtualizer(input: Input) { ), ) + // Only the viewport carries the inset as a variable, for its fade and scroll padding. The variable does not inherit + // (see index.css): the inset changes on every frame while the composer region grows, and an inherited variable would + // restyle every rendered row each time. + createEffect(() => listRoot()?.style.setProperty("--session-timeline-inset", `${bottomInset()}px`)) + let coldPending = coldBottomMount let settleQueued = false let contentObserver: MutationObserver | undefined @@ -960,21 +965,16 @@ export function createTimelineVirtualizer(input: Input) { } return ( -
+
-