fix(app): stop dropping frames while a queued prompt grows in (#60216)

Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-29 15:09:40 +00:00
committed by GitHub
Unverified
parent 82666b6612
commit 304e835bfb
6 changed files with 103 additions and 59 deletions
+3 -1
View File
@@ -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)
+14
View File
@@ -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: "<length>";
inherits: false;
initial-value: 0px;
}
@property --session-timeline-fade-start {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
@@ -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<HTMLElement>('[data-component="session-queue-row"]')], step)
stopCascade = cascade(
[...listRef.querySelectorAll<HTMLElement>('[data-component="session-queue-row"]')],
step,
)
})
},
{ defer: true },
@@ -145,16 +153,16 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo
}
function keepLeaving<T extends { id: string }>(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 (
<Show when={row()} keyed>
<Show when={row()}>
{(entry) => (
<div
ref={sortable.ref}
data-component={leaving() ? undefined : "session-queue-row"}
data-leaving={leaving() || undefined}
aria-busy={leaving() ? undefined : entry.pending}
aria-busy={leaving() ? undefined : entry().pending}
class="group/queue-row mb-px flex h-8 shrink-0 items-center justify-between gap-2 rounded-md py-1 ps-0.5 pe-2"
classList={{
"bg-v2-overlay-simple-overlay-hover": editing(),
@@ -209,22 +219,22 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
type="button"
data-action="session-queue-edit"
dir="auto"
aria-disabled={entry.pending}
aria-disabled={entry().pending}
class="max-w-full min-w-0 self-start truncate rounded-sm text-start text-[13px] font-[440] leading-[var(--line-height-compact)]"
classList={{
"text-v2-text-text-faint": editing() || entry.pending,
"cursor-progress": entry.pending,
"text-v2-text-text-faint": editing() || entry().pending,
"cursor-progress": entry().pending,
"cursor-text text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover":
!editing() && !entry.pending,
!editing() && !entry().pending,
}}
onClick={() => 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) : "")}
</button>
<Show when={entry.attachments && entry.text}>
<Show when={entry().attachments && entry().text}>
<span class="shrink-0 whitespace-nowrap text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
{language.plural("session.queue.attachments", entry.attachments)}
{language.plural("session.queue.attachments", entry().attachments)}
</span>
</Show>
</div>
@@ -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(),
}}
>
<Show when={entry.pending}>
<Show when={entry().pending}>
<Loader class="size-3.5 text-v2-text-text-faint" />
</Show>
<Show when={!editing() && !entry.pending}>
<Show when={!editing() && !entry().pending}>
<Tooltip
placement="top"
inactive={!props.queue.working()}
@@ -273,7 +283,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
/>
</Tooltip>
</Show>
<Show when={!entry.pending}>
<Show when={!entry().pending}>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
data-action="session-queue-remove"
@@ -454,16 +454,9 @@ function grow(
return
}
const mb = parseFloat(getComputedStyle(el).marginBottom) || 0
const prevMb =
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
const state: Growth = {
spring: Spring.fromDuration(
open ? 0 : el.getBoundingClientRect().height,
reduced ? fade : open ? morph() : (close ?? morph()),
),
// A closing box starts from its height, which the first frame measures.
spring: Spring.fromDuration(0, reduced ? fade : open ? morph() : (close ?? morph())),
// Opening fades in on its own spring; closing fades with the height, inside the closing box.
presence: Spring.fromDuration(open ? 0 : 1, reduced ? fade : fadeIn()),
open,
@@ -474,23 +467,20 @@ function grow(
el.toggleAttribute("data-moving", true)
el.toggleAttribute("data-dock-border-underlay", !!card)
if (!reduced) {
el.style.height = `${snap(state.spring.value)}px`
if (open && mb > 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()
@@ -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?.()
@@ -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 (
<div
ref={setViewRoot}
class="relative w-full h-full min-w-0"
style={{
"--session-timeline-inset": `${bottomInset()}px`,
}}
>
<div ref={setViewRoot} class="relative w-full h-full min-w-0">
<div
class="absolute bottom-[calc(var(--session-timeline-inset)+32px)] left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
classList={{
"opacity-100 translate-y-0 duration-150 ease-[cubic-bezier(.2,.8,.2,1)]":
input.scroll().overflow && input.scroll().jump,
"opacity-0 translate-y-1 motion-reduce:translate-y-0 duration-[110ms] ease-[cubic-bezier(.4,0,1,1)]":
!input.scroll().overflow || !input.scroll().jump,
}}
style={{ bottom: `${bottomInset() + 32}px` }}
inert={!input.scroll().overflow || !input.scroll().jump}
>
<button
@@ -1051,12 +1051,13 @@ export function createTimelineVirtualizer(input: Input) {
{props.bottomSpacer}
</div>
</div>
<div aria-hidden="true" data-timeline-inset class="h-[var(--session-timeline-inset)] shrink-0" />
<div aria-hidden="true" data-timeline-inset class="shrink-0" style={{ height: `${bottomInset()}px` }} />
</ScrollView>
<div
ref={setVisibleArea}
data-slot="session-timeline-visible"
class="pointer-events-none absolute inset-x-0 top-0 bottom-[var(--session-timeline-inset)]"
class="pointer-events-none absolute inset-x-0 top-0"
style={{ bottom: `${bottomInset()}px` }}
/>
</div>
)