mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
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:
committed by
GitHub
Unverified
parent
82666b6612
commit
304e835bfb
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user