Skip to content

Commit 304e835

Browse files
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>
1 parent 82666b6 commit 304e835

6 files changed

Lines changed: 104 additions & 60 deletions

File tree

‎packages/app/src/composer/editor/editor.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,9 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
528528
}),
529529
)
530530

531-
if (!active) return
531+
// Emptying the editor already collapses its caret to the start, which is also its end. Any selection call, even a
532+
// read, forces a layout, and a submit empties the editor from its key handler.
533+
if (!active || prompt.every((part) => isAttachment(part) || (part.type === "text" && !part.content))) return
532534
const selection = window.getSelection()
533535
const range = document.createRange()
534536
range.selectNodeContents(editor)

‎packages/app/src/index.css‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
@import "@opencode/ui/styles/tokens";
44
@import "tw-animate-css";
55

6+
/* Set on the timeline viewport and changed on every frame while the composer region grows. Neither inherits, so a change
7+
restyles the viewport alone instead of every row inside it. */
8+
@property --session-timeline-inset {
9+
syntax: "<length>";
10+
inherits: false;
11+
initial-value: 0px;
12+
}
13+
14+
@property --session-timeline-fade-start {
15+
syntax: "<length>";
16+
inherits: false;
17+
initial-value: 0px;
18+
}
19+
620
@font-face {
721
font-family: "JetBrainsMono Nerd Font Mono";
822
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");

‎packages/app/src/session/composer/queue-panel.tsx‎

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,20 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo
3838
(next) => {
3939
const prevLive = lastLive
4040
lastLive = next
41+
4142
if (next.length === 0) return
4243

4344
// An in-place edit or reorder keeps the row count: a row whose ID it replaced swaps in place rather than closing,
4445
// while a row that was already closing keeps closing until its exit completes.
4546
const swap = next.length === prevLive.length && prevLive.length > 0
47+
4648
if (!swap && prevLive.length > 0 && !props.hidden)
4749
next.forEach((row) => !prevLive.some((item) => item.id === row.id) && appearing.add(row.id))
50+
4851
const kept = untrack(shown).filter(
4952
(row) => !swap || next.some((item) => item.id === row.id) || !prevLive.some((item) => item.id === row.id),
5053
)
54+
5155
setShown(keepLeaving(kept, next))
5256
},
5357
{ defer: true },
@@ -63,10 +67,14 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo
6367
stopCascade?.()
6468
stopCascade = undefined
6569
const step = queueStagger()
70+
6671
if (!now || was !== false || step <= 0 || prefersReducedMotion()) return
6772
queueMicrotask(() => {
6873
if (!open()) return
69-
stopCascade = cascade([...listRef.querySelectorAll<HTMLElement>('[data-component="session-queue-row"]')], step)
74+
stopCascade = cascade(
75+
[...listRef.querySelectorAll<HTMLElement>('[data-component="session-queue-row"]')],
76+
step,
77+
)
7078
})
7179
},
7280
{ defer: true },
@@ -145,16 +153,16 @@ export function SessionQueuePanel(props: { queue: SessionQueueView; hidden?: boo
145153
}
146154

147155
function keepLeaving<T extends { id: string }>(previous: T[], next: T[]) {
148-
return previous.reduce(
149-
(rows, row, index) => {
150-
if (rows.some((item) => item.id === row.id)) return rows
151-
const before = previous.slice(0, index).findLast((item) => rows.some((entry) => entry.id === item.id))
152-
const at = before ? rows.findIndex((item) => item.id === before.id) + 1 : 0
156+
const rows = [...next]
153157

154-
return [...rows.slice(0, at), row, ...rows.slice(at)]
155-
},
156-
[...next],
157-
)
158+
previous.forEach((row, index) => {
159+
if (rows.some((item) => item.id === row.id)) return
160+
const before = previous.slice(0, index).findLast((item) => rows.some((entry) => entry.id === item.id))
161+
const at = before ? rows.findIndex((item) => item.id === before.id) + 1 : 0
162+
rows.splice(at, 0, row)
163+
})
164+
165+
return rows
158166
}
159167

160168
function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) {
@@ -179,14 +187,16 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
179187
},
180188
})
181189

190+
// Not keyed: the queue rebuilds every row object whenever it changes, and a keyed row would remount, tooltips and
191+
// all, each time a prompt is added or the server confirms one.
182192
return (
183-
<Show when={row()} keyed>
193+
<Show when={row()}>
184194
{(entry) => (
185195
<div
186196
ref={sortable.ref}
187197
data-component={leaving() ? undefined : "session-queue-row"}
188198
data-leaving={leaving() || undefined}
189-
aria-busy={leaving() ? undefined : entry.pending}
199+
aria-busy={leaving() ? undefined : entry().pending}
190200
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"
191201
classList={{
192202
"bg-v2-overlay-simple-overlay-hover": editing(),
@@ -209,22 +219,22 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
209219
type="button"
210220
data-action="session-queue-edit"
211221
dir="auto"
212-
aria-disabled={entry.pending}
222+
aria-disabled={entry().pending}
213223
class="max-w-full min-w-0 self-start truncate rounded-sm text-start text-[13px] font-[440] leading-[var(--line-height-compact)]"
214224
classList={{
215-
"text-v2-text-text-faint": editing() || entry.pending,
216-
"cursor-progress": entry.pending,
225+
"text-v2-text-text-faint": editing() || entry().pending,
226+
"cursor-progress": entry().pending,
217227
"cursor-text text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover":
218-
!editing() && !entry.pending,
228+
!editing() && !entry().pending,
219229
}}
220230
onClick={() => props.queue.edit(props.id)}
221231
>
222-
{entry.text ||
223-
(entry.attachments ? language.plural("session.queue.attachments", entry.attachments) : "")}
232+
{entry().text ||
233+
(entry().attachments ? language.plural("session.queue.attachments", entry().attachments) : "")}
224234
</button>
225-
<Show when={entry.attachments && entry.text}>
235+
<Show when={entry().attachments && entry().text}>
226236
<span class="shrink-0 whitespace-nowrap text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
227-
{language.plural("session.queue.attachments", entry.attachments)}
237+
{language.plural("session.queue.attachments", entry().attachments)}
228238
</span>
229239
</Show>
230240
</div>
@@ -234,14 +244,14 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
234244
class="flex shrink-0 items-center gap-1.5"
235245
classList={{
236246
"opacity-0 focus-within:opacity-100 group-hover/queue-row:opacity-100 [@media(hover:none)]:opacity-100":
237-
!active() && !editing() && !entry.pending,
247+
!active() && !editing() && !entry().pending,
238248
"pointer-events-none": props.queue.busy() || leaving(),
239249
}}
240250
>
241-
<Show when={entry.pending}>
251+
<Show when={entry().pending}>
242252
<Loader class="size-3.5 text-v2-text-text-faint" />
243253
</Show>
244-
<Show when={!editing() && !entry.pending}>
254+
<Show when={!editing() && !entry().pending}>
245255
<Tooltip
246256
placement="top"
247257
inactive={!props.queue.working()}
@@ -273,7 +283,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
273283
/>
274284
</Tooltip>
275285
</Show>
276-
<Show when={!entry.pending}>
286+
<Show when={!entry().pending}>
277287
<Tooltip placement="top" value={language.t("session.queue.remove")}>
278288
<IconButton
279289
data-action="session-queue-remove"

‎packages/app/src/session/composer/spring-height.tsx‎

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -454,16 +454,9 @@ function grow(
454454
return
455455
}
456456

457-
const mb = parseFloat(getComputedStyle(el).marginBottom) || 0
458-
459-
const prevMb =
460-
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
461-
462457
const state: Growth = {
463-
spring: Spring.fromDuration(
464-
open ? 0 : el.getBoundingClientRect().height,
465-
reduced ? fade : open ? morph() : (close ?? morph()),
466-
),
458+
// A closing box starts from its height, which the first frame measures.
459+
spring: Spring.fromDuration(0, reduced ? fade : open ? morph() : (close ?? morph())),
467460
// Opening fades in on its own spring; closing fades with the height, inside the closing box.
468461
presence: Spring.fromDuration(open ? 0 : 1, reduced ? fade : fadeIn()),
469462
open,
@@ -474,23 +467,20 @@ function grow(
474467
el.toggleAttribute("data-moving", true)
475468
el.toggleAttribute("data-dock-border-underlay", !!card)
476469

477-
if (!reduced) {
478-
el.style.height = `${snap(state.spring.value)}px`
479-
480-
if (open && mb > 0) el.style.marginBottom = "0px"
481-
482-
if (open && prevMb > 0) el.style.marginTop = `${-prevMb}px`
483-
}
470+
// Writes only until the first frame: a grow usually starts inside an input handler that has just changed the DOM,
471+
// where a read would force a layout that the frame then repeats.
472+
if (open && !reduced) el.style.height = "0px"
484473

485474
if ((withFade || reduced) && open) el.style.opacity = "0"
486-
487-
// The content's height is read once here, then kept current by a ResizeObserver rather than read every frame.
488475
const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : undefined
489-
const margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0
490-
491-
if (margin < 0) el.style.overflowClipMargin = `${-margin}px`
492-
let full = child ? child.getBoundingClientRect().height + margin : 0
493-
476+
let mb = 0
477+
let prevMb = 0
478+
let margin = 0
479+
let full = 0
480+
let measured = false
481+
482+
// The content's height is read once, on the first frame, then kept current by a ResizeObserver rather than read
483+
// every frame.
494484
const observer = new ResizeObserver((entries) => {
495485
const size = entries[0]?.borderBoxSize[0]?.blockSize
496486

@@ -499,6 +489,19 @@ function grow(
499489

500490
if (child) observer.observe(child)
501491

492+
// Runs in the frame loop's read phase, before any of the frame's writes.
493+
const measure = () => {
494+
if (measured || !el.isConnected) return
495+
measured = true
496+
mb = parseFloat(getComputedStyle(el).marginBottom) || 0
497+
prevMb =
498+
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
499+
margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0
500+
full = child ? child.getBoundingClientRect().height + margin : 0
501+
502+
if (!open) state.spring.snap(el.getBoundingClientRect().height)
503+
}
504+
502505
const stop = frameLoop.add((dt) => {
503506
if (!el.isConnected) {
504507
observer.disconnect()
@@ -507,6 +510,8 @@ function grow(
507510
return false
508511
}
509512

513+
if (margin < 0) el.style.overflowClipMargin = `${-margin}px`
514+
510515
const noMotion = prefersReducedMotion()
511516
const size = state.open ? full : 0
512517
state.spring.set(size).step(dt)
@@ -550,7 +555,7 @@ function grow(
550555
}
551556

552557
return true
553-
})
558+
}, measure)
554559

555560
state.end = () => {
556561
stop()

‎packages/app/src/session/timeline/interaction.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,20 @@ export function createSessionTimelineInteraction(session: SessionModel) {
176176
consumePendingMessage: (key) => layout.pendingMessage.consume(key),
177177
})
178178

179+
let resumeFrame: (() => void) | undefined
180+
179181
const resume = (behavior?: ScrollBehavior) => {
180182
setState("messageID", undefined)
181183
pin()
182-
scrollToEnd(behavior)
184+
// Scrolls on the next frame, before it paints. A submit resumes from its key handler right after changing the
185+
// DOM, where reading the scroll range would force a layout that the frame then repeats.
186+
resumeFrame?.()
187+
resumeFrame = frameLoop.add(() => {
188+
resumeFrame = undefined
189+
scrollToEnd(behavior)
190+
191+
return false
192+
})
183193
clearMessageHash()
184194

185195
if (scroller) scheduleScrollState(scroller)
@@ -382,6 +392,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
382392
onCleanup(() => {
383393
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
384394

395+
resumeFrame?.()
396+
385397
scrollStateFrame?.()
386398

387399
fillFrame?.()

‎packages/app/src/session/timeline/virtualizer.tsx‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@ export function createTimelineVirtualizer(input: Input) {
616616
),
617617
)
618618

619+
// Only the viewport carries the inset as a variable, for its fade and scroll padding. The variable does not inherit
620+
// (see index.css): the inset changes on every frame while the composer region grows, and an inherited variable would
621+
// restyle every rendered row each time.
622+
createEffect(() => listRoot()?.style.setProperty("--session-timeline-inset", `${bottomInset()}px`))
623+
619624
let coldPending = coldBottomMount
620625
let settleQueued = false
621626
let contentObserver: MutationObserver | undefined
@@ -960,21 +965,16 @@ export function createTimelineVirtualizer(input: Input) {
960965
}
961966

962967
return (
963-
<div
964-
ref={setViewRoot}
965-
class="relative w-full h-full min-w-0"
966-
style={{
967-
"--session-timeline-inset": `${bottomInset()}px`,
968-
}}
969-
>
968+
<div ref={setViewRoot} class="relative w-full h-full min-w-0">
970969
<div
971-
class="absolute bottom-[calc(var(--session-timeline-inset)+32px)] left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
970+
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
972971
classList={{
973972
"opacity-100 translate-y-0 duration-150 ease-[cubic-bezier(.2,.8,.2,1)]":
974973
input.scroll().overflow && input.scroll().jump,
975974
"opacity-0 translate-y-1 motion-reduce:translate-y-0 duration-[110ms] ease-[cubic-bezier(.4,0,1,1)]":
976975
!input.scroll().overflow || !input.scroll().jump,
977976
}}
977+
style={{ bottom: `${bottomInset() + 32}px` }}
978978
inert={!input.scroll().overflow || !input.scroll().jump}
979979
>
980980
<button
@@ -1051,12 +1051,13 @@ export function createTimelineVirtualizer(input: Input) {
10511051
{props.bottomSpacer}
10521052
</div>
10531053
</div>
1054-
<div aria-hidden="true" data-timeline-inset class="h-[var(--session-timeline-inset)] shrink-0" />
1054+
<div aria-hidden="true" data-timeline-inset class="shrink-0" style={{ height: `${bottomInset()}px` }} />
10551055
</ScrollView>
10561056
<div
10571057
ref={setVisibleArea}
10581058
data-slot="session-timeline-visible"
1059-
class="pointer-events-none absolute inset-x-0 top-0 bottom-[var(--session-timeline-inset)]"
1059+
class="pointer-events-none absolute inset-x-0 top-0"
1060+
style={{ bottom: `${bottomInset()}px` }}
10601061
/>
10611062
</div>
10621063
)

0 commit comments

Comments
 (0)